Launch validation (#414)

* WIP

* Cleanup

* Validation

* typo

* Max category limit 100 -> 500
This commit is contained in:
Charlotte Weaver
2018-11-07 13:01:13 -08:00
committed by GitHub
parent a9189017c7
commit 7d5c054b90
10 changed files with 204 additions and 141 deletions
+3 -3
View File
@@ -14,9 +14,9 @@ class CXGDriver(metaclass=ABCMeta):
def __init__(self, data, args):
self.data = self._load_data(data)
self.layout_method = args['layout']
self.diffexp_method = args['diffexp']
self.max_category_items = args['max_category_items']
self.layout_method = args["layout"]
self.diffexp_method = args["diffexp"]
self.max_category_items = args["max_category_items"]
self.cluster = None
@property
+2 -1
View File
@@ -10,7 +10,8 @@ from werkzeug.datastructures import ImmutableMultiDict
from server.app.util.constants import Axis, DiffExpMode
from server.app.util.filter import parse_filter, QueryStringError
from server.app.util.models import FilterModel
from server.app.util.utils import FilterError, InteractiveError, MimeTypeError, PrepareError, get_mime_type
from server.app.util.utils import get_mime_type
from server.app.util.errors import MimeTypeError, FilterError, InteractiveError, PrepareError
"""
Sort order for routes
+52 -18
View File
@@ -2,12 +2,13 @@ import warnings
import numpy as np
from pandas import DataFrame
from pandas.core.dtypes.dtypes import CategoricalDtype
import scanpy.api as sc
from scipy import stats, sparse
from server.app.driver.driver import CXGDriver
from server.app.util.constants import Axis, DEFAULT_TOP_N, DiffExpMode
from server.app.util.utils import FilterError, InteractiveError, PrepareError
from server.app.util.errors import FilterError, InteractiveError, PrepareError, ScanpyFileError
"""
Sort order for methods
@@ -23,9 +24,10 @@ class ScanpyEngine(CXGDriver):
def __init__(self, data, args):
super().__init__(data, args)
self._alias_annotation_names(Axis.OBS, args['obs_names'])
self._alias_annotation_names(Axis.VAR, args['var_names'])
self._alias_annotation_names(Axis.OBS, args["obs_names"])
self._alias_annotation_names(Axis.VAR, args["var_names"])
self._validate_data_types()
self._validate_data_calculations()
self.cell_count = self.data.shape[0]
self.gene_count = self.data.shape[1]
self.layout_options = ["umap", "tsne"]
@@ -39,27 +41,28 @@ class ScanpyEngine(CXGDriver):
As a *critical* side-effect, ensure the indices are simple number ranges
(accomplished by calling pandas.DataFrame.reset_index())
"""
if name == 'name':
if name == "name":
# a noop, so skip it
return
ax_name = str(axis)
df_axis = getattr(self.data, ax_name)
if name is None:
# reset index to simple range; alias 'name' to point at the
# reset index to simple range; alias "name" to point at the
# previously specified index.
df_axis = df_axis.reset_index().rename(columns={'index': 'name'})
df_axis.reset_index(inplace=True)
df_axis.rename(inplace=True, columns={"index": "name"})
elif name in df_axis.columns:
if name not in df_axis.columns:
raise KeyError(f"Annotation name {name}, specified in --{ax_name}-name does not exist.")
if not df_axis[name].is_unique:
raise KeyError(f"Values in -{ax_name}-name must be unique. "
"Please prepare data to contain unique values.")
# reset index to simple range; alias user-specified annotation to 'name'
df_axis = df_axis.reset_index(drop=True).rename(columns={name: 'name'})
# reset index to simple range; alias user-specified annotation to "name"
df_axis.reset_index(drop=True, inplace=True)
df_axis.rename(inplace=True, columns={name: "name"})
else:
raise KeyError(f"Annotation name {name}, specified in --{ax_name}_name does not exist.")
setattr(self.data, ax_name, df_axis)
def _create_schema(self):
self.schema = {
@@ -78,9 +81,9 @@ class ScanpyEngine(CXGDriver):
for ann in curr_axis:
ann_schema = {"name": ann}
data_kind = curr_axis[ann].dtype.kind
if data_kind == 'f':
if data_kind == "f":
ann_schema["type"] = "float32"
elif data_kind in ['i', 'u']:
elif data_kind in ["i", "u"]:
ann_schema["type"] = "int32"
elif data_kind == "?":
ann_schema["type"] = "boolean"
@@ -98,7 +101,18 @@ class ScanpyEngine(CXGDriver):
# Based on benchmarking, cache=True has no impact on perf.
# Note: as of current scanpy/anndata release, setting backed='r' will
# result in an error. https://github.com/theislab/anndata/issues/79
return sc.read(data, cache=False)
try:
result = sc.read(data, cache=True)
except ValueError:
raise ScanpyFileError("File must be in the .h5ad format. Please read "
"https://github.com/theislab/scanpy_usage/blob/master/170505_seurat/info_h5ad.md to "
"learn more about this format. You may be able to convert your file into this format "
"using `cellxgene prepare`, please run `cellxgene prepare --help` for more "
"information.")
except Exception as e:
raise ScanpyFileError(f"Error while loading file: {e}, File must be in the .h5ad format, please check "
f"that your input and try again.")
return result
@staticmethod
def _top_sort(values, sort_order, top_n=None):
@@ -137,14 +151,34 @@ class ScanpyEngine(CXGDriver):
curr_axis = getattr(self.data, str(ax))
for ann in curr_axis:
datatype = curr_axis[ann].dtype
downcast_map = {'int64': 'int32',
'uint32': 'int32',
'uint64': 'int32',
'float64': 'float32',
downcast_map = {"int64": "int32",
"uint32": "int32",
"uint64": "int32",
"float64": "float32",
}
if datatype in downcast_map:
warnings.warn(f"Scanpy annotation {ax}:{ann} is in unsupported format: {datatype}. "
f"Data will be downcast to {downcast_map[datatype]}.")
if isinstance(datatype, CategoricalDtype):
category_num = len(curr_axis[ann].dtype.categories)
if category_num > 500 and category_num > self.max_category_items:
warnings.warn(
f"{str(ax).title()} annotation '{ann}' has {category_num} categories, this may be "
f"cumbersome or slow to display. We recommend setting the "
f"--max-category-items option to 500, this will hide categorical "
f"annotations with more than 500 categories in the UI")
def _validate_data_calculations(self):
layout_key = f"X_{self.layout_method}"
try:
assert layout_key in self.data.obsm_keys()
except AssertionError:
raise PrepareError(
f"Cannot find a field with coordinates for the {self.layout_method} layout requested. A different"
f" layout may have been computed. The requested layout must be pre-calculated and saved "
f"back in the h5ad file. You can run "
f"`cellxgene prepare --layout {self.layout_method} <datafile>` "
f"to solve this problem. ")
def filter_dataframe(self, filter, include_uns=False):
"""
@@ -232,8 +266,8 @@ class ScanpyEngine(CXGDriver):
https://docs.scipy.org/doc/scipy/reference/sparse.html
"""
prefer_row_access = sparse.isspmatrix_csr(data._X) or \
sparse.isspmatrix_lil(data._X) or sparse.isspmatrix_bsr(data._X)
prefer_row_access = sparse.isspmatrix_csr(data._X) or sparse.isspmatrix_lil(data._X) \
or sparse.isspmatrix_bsr(data._X)
if prefer_row_access:
# Row-major slicing
if obs_selector is not None:
+43
View File
@@ -0,0 +1,43 @@
class FilterError(Exception):
"""
Raised when filter is malformed
"""
def __init__(self, message):
self.message = message
class InteractiveError(Exception):
"""
Raised when computation would exceed interactive time
"""
def __init__(self, message):
self.message = message
class MimeTypeError(Exception):
"""
Raised when incompatible MIME type selected
"""
def __init__(self, message):
self.message = message
class PrepareError(Exception):
"""
Raised when data is misprepared
"""
def __init__(self, message):
self.message = message
class ScanpyFileError(Exception):
"""
Raised when file loaded into scanpy is misformatted
"""
def __init__(self, message):
self.message = message
+2 -24
View File
@@ -3,6 +3,8 @@ from argparse import ArgumentTypeError
from numpy import float32, integer
from server.app.util.errors import MimeTypeError
class Float32JSONEncoder(json.JSONEncoder):
def default(self, obj):
@@ -13,30 +15,6 @@ class Float32JSONEncoder(json.JSONEncoder):
return json.JSONEncoder.default(self, obj)
class MimeTypeError(Exception):
def __init__(self, message):
self.message = message
class FilterError(Exception):
def __init__(self, message):
self.message = message
class InteractiveError(Exception):
def __init__(self, message):
self.message = message
class PrepareError(Exception):
def __init__(self, message):
self.message = message
def get_mime_type(default="application/json", acceptable_types=["application/json", "text/csv"], query_param=None,
header=None):
mime_type = default
+2 -2
View File
@@ -4,8 +4,8 @@ from .launch import launch
from .prepare import prepare
@click.group(name='cellxgene', context_settings=dict(max_content_width=85))
@click.version_option(version='0.0.1', prog_name='cellxgene', message='[%(prog)s] Version %(version)s')
@click.group(name="cellxgene", context_settings=dict(max_content_width=85))
@click.version_option(version="0.0.1", prog_name="cellxgene", message="[%(prog)s] Version %(version)s")
def cli():
pass
+42 -33
View File
@@ -1,31 +1,32 @@
import sys
import click
import logging
from os.path import splitext, basename
import webbrowser
from os.path import splitext, basename
from server.app.util.errors import ScanpyFileError
@click.command()
@click.argument('data', metavar='<data file>', type=click.Path(exists=True, file_okay=True, dir_okay=False))
@click.option('--layout', '-l', type=click.Choice(['umap', 'tsne']), default='umap', show_default=True,
help='Method for layout.')
@click.option('--diffexp', '-d', type=click.Choice(['ttest']), default='ttest', show_default=True,
help='Method for differential expression.')
@click.option('--title', '-t', help='Title to display (if omitted will use file name).', metavar='')
@click.option('--verbose', '-v', is_flag=True, default=False, show_default=True,
help='Provide verbose output, including warnings and all server requests.')
@click.option('--debug', '-d', is_flag=True, default=False, show_default=True,
help='Run in debug mode.')
@click.option('--open', '-o', 'open_browser', is_flag=True, default=False, show_default=True,
help='Open the web browser after launch.')
@click.option('--port', '-p', help="Port to run server on.", metavar='', default=5005, show_default=True)
@click.option('--obs-names', default=None, metavar='', help='Name of annotation field to use for observations.')
@click.option('--var-names', default=None, metavar='', help='Name of annotation to use for variables.')
@click.option('--listen-all', is_flag=True, default=False, show_default=True,
help='Bind to all interfaces (this makes the server accessible beyond this computer).')
@click.option('--max-category-items', default=100, metavar='', show_default=True,
help='Limits the number of categorical annotation items displayed.')
@click.argument("data", metavar="<data file>", type=click.Path(exists=True, file_okay=True, dir_okay=False))
@click.option("--layout", "-l", type=click.Choice(["umap", "tsne"]), default="umap", show_default=True,
help="Method for layout.")
@click.option("--diffexp", "-d", type=click.Choice(["ttest"]), default="ttest", show_default=True,
help="Method for differential expression.")
@click.option("--title", "-t", help="Title to display (if omitted will use file name).", metavar="")
@click.option("--verbose", "-v", is_flag=True, default=False, show_default=True,
help="Provide verbose output, including warnings and all server requests.")
@click.option("--debug", "-d", is_flag=True, default=False, show_default=True,
help="Run in debug mode.")
@click.option("--open", "-o", "open_browser", is_flag=True, default=False, show_default=True,
help="Open the web browser after launch.")
@click.option("--port", "-p", help="Port to run server on.", metavar="", default=5005, show_default=True)
@click.option("--obs-names", default=None, metavar="", help="Name of annotation field to use for observations.")
@click.option("--var-names", default=None, metavar="", help="Name of annotation to use for variables.")
@click.option("--listen-all", is_flag=True, default=False, show_default=True,
help="Bind to all interfaces (this makes the server accessible beyond this computer).")
@click.option("--max-category-items", default=100, metavar="", show_default=True,
help="Limits the number of categorical annotation items displayed.")
def launch(data, layout, diffexp, title, verbose, debug, obs_names, var_names,
open_browser, port, listen_all, max_category_items):
"""Launch the cellxgene data viewer.
@@ -40,15 +41,15 @@ def launch(data, layout, diffexp, title, verbose, debug, obs_names, var_names,
> cellxgene launch <your data file> --title <your title>"""
# Startup message
click.echo('[cellxgene] Starting the CLI...')
click.echo("[cellxgene] Starting the CLI...")
# Import Flask app
from server.app.app import app
# Argument checking
name, extension = splitext(data)
if extension != '.h5ad':
raise click.FileError(basename(data), hint='file type must be .h5ad')
if extension != ".h5ad":
raise click.FileError(basename(data), hint="file type must be .h5ad")
if debug:
verbose = True
@@ -62,9 +63,9 @@ def launch(data, layout, diffexp, title, verbose, debug, obs_names, var_names,
title = file_parts[0]
if listen_all:
host = '0.0.0.0'
host = "0.0.0.0"
else:
host = '127.0.0.1'
host = "127.0.0.1"
# Setup app
cellxgene_url = f"http://{host}:{port}"
@@ -76,24 +77,32 @@ def launch(data, layout, diffexp, title, verbose, debug, obs_names, var_names,
)
if not verbose:
log = logging.getLogger('werkzeug')
log = logging.getLogger("werkzeug")
log.setLevel(logging.ERROR)
click.echo(f'[cellxgene] Loading data from {basename(data)}, this may take awhile...')
click.echo(f"[cellxgene] Loading data from {basename(data)}, this may take awhile...")
from server.app.scanpy_engine.scanpy_engine import ScanpyEngine
args = {'layout': layout, 'diffexp': diffexp, 'max_category_items': max_category_items,
'obs_names': obs_names, 'var_names': var_names}
args = {
"layout": layout,
"diffexp": diffexp,
"max_category_items": max_category_items,
"obs_names": obs_names,
"var_names": var_names
}
app.data = ScanpyEngine(data, args)
try:
app.data = ScanpyEngine(data, args)
except ScanpyFileError as e:
raise click.ClickException(f"{e}")
if open_browser:
click.echo(f'[cellxgene] Launching! Opening your browser to {cellxgene_url} now.')
click.echo(f"[cellxgene] Launching! Opening your browser to {cellxgene_url} now.")
webbrowser.open(cellxgene_url)
else:
click.echo(f'[cellxgene] Launching! Please go to {cellxgene_url} in your browser.')
click.echo(f"[cellxgene] Launching! Please go to {cellxgene_url} in your browser.")
click.echo('[cellxgene] Type CTRL-C at any time to exit.')
click.echo("[cellxgene] Type CTRL-C at any time to exit.")
app.run(host=host, debug=debug, port=port, threaded=True)
+56 -56
View File
@@ -1,26 +1,26 @@
import click
from os.path import expanduser, isdir, isfile, sep, splitext
from numpy import unique, ndarray
import click
from numpy import ndarray, unique
from scipy.sparse.csc import csc_matrix
from os.path import isfile, isdir, splitext, expanduser, sep
@click.command()
@click.argument('data', nargs=1, metavar='<dataset: file or path to data>', required=True)
@click.option('--layout', '-l', default=['umap', 'tsne'], multiple=True, type=click.Choice(['umap', 'tsne']),
help='Layout algorithm', show_default=True)
@click.option('--recipe', '-r', default='none', type=click.Choice(['none', 'seurat', 'zheng17']),
help='Preprocessing to run.', show_default=True)
@click.option('--output', '-o', default='', help='Save a new file to filename.', metavar='<filename>')
@click.option('--plotting', '-p', default=False, is_flag=True, help='Whether to generate plots.', show_default=True)
@click.option('--sparse', default=False, is_flag=True, help='Whether to force sparsity.', show_default=True)
@click.option('--overwrite', default=False, is_flag=True, help='Allow file overwriting.', show_default=True)
@click.option('--set-obs-names', default='', help='Named field to set as index for obs.', metavar='<name>')
@click.option('--set-var-names', default='', help='Named field to set as index for var.', metavar='<name>')
@click.option('--make-obs-names-unique', default=True, is_flag=True,
help='Ensure obs index is unique.', show_default=True)
@click.option('--make-var-names-unique', default=True, is_flag=True,
help='Ensure var index is unique.', show_default=True)
@click.argument("data", nargs=1, metavar="<dataset: file or path to data>", required=True)
@click.option("--layout", "-l", default=["umap", "tsne"], multiple=True, type=click.Choice(["umap", "tsne"]),
help="Layout algorithm", show_default=True)
@click.option("--recipe", "-r", default="none", type=click.Choice(["none", "seurat", "zheng17"]),
help="Preprocessing to run.", show_default=True)
@click.option("--output", "-o", default="", help="Save a new file to filename.", metavar="<filename>")
@click.option("--plotting", "-p", default=False, is_flag=True, help="Whether to generate plots.", show_default=True)
@click.option("--sparse", default=False, is_flag=True, help="Whether to force sparsity.", show_default=True)
@click.option("--overwrite", default=False, is_flag=True, help="Allow file overwriting.", show_default=True)
@click.option("--set-obs-names", default="", help="Named field to set as index for obs.", metavar="<name>")
@click.option("--set-var-names", default="", help="Named field to set as index for var.", metavar="<name>")
@click.option("--make-obs-names-unique", default=True, is_flag=True,
help="Ensure obs index is unique.", show_default=True)
@click.option("--make-var-names-unique", default=True, is_flag=True,
help="Ensure var index is unique.", show_default=True)
def prepare(data, layout, recipe, output, plotting, sparse, overwrite,
set_obs_names, set_var_names, make_obs_names_unique, make_var_names_unique):
"""Preprocesses data for use with cellxgene.
@@ -33,9 +33,9 @@ def prepare(data, layout, recipe, output, plotting, sparse, overwrite,
annotations, ensuring sparsity, and plotting results."""
# collect slow imports here to make CLI startup more responsive
click.echo('[cellxgene] Starting CLI...')
click.echo("[cellxgene] Starting CLI...")
import matplotlib
matplotlib.use('Agg')
matplotlib.use("Agg")
import scanpy.api as sc
# scanpy settings
@@ -43,45 +43,45 @@ def prepare(data, layout, recipe, output, plotting, sparse, overwrite,
sc.settings.autosave = True
# check args
if sparse and not recipe == 'none':
raise click.UsageError('Cannot use a recipe when forcing sparsity')
if sparse and not recipe == "none":
raise click.UsageError("Cannot use a recipe when forcing sparsity")
output = expanduser(output)
if isfile(output) and not overwrite:
raise click.UsageError(f'Cannot overwrite existing file {output}, try using the flag --overwrite')
raise click.UsageError(f"Cannot overwrite existing file {output}, try using the flag --overwrite")
def load_data(data):
if isfile(data):
name, extension = splitext(data)
if extension == '.h5ad':
if extension == ".h5ad":
adata = sc.read_h5ad(data)
elif extension == '.loom':
elif extension == ".loom":
adata = sc.read_loom(data)
else:
raise click.FileError(data, hint='does not have a valid extension [.h5ad | .loom]')
raise click.FileError(data, hint="does not have a valid extension [.h5ad | .loom]")
elif isdir(data):
if not data.endswith(sep):
data += sep
adata = sc.read_10x_mtx(data)
else:
raise click.FileError(data, hint='not a valid file or path')
raise click.FileError(data, hint="not a valid file or path")
if not set_obs_names == '':
if not set_obs_names == "":
if set_obs_names not in adata.obs_keys():
raise click.UsageError(f'obs {set_obs_names} not found, options are: {adata.obs_keys()}')
raise click.UsageError(f"obs {set_obs_names} not found, options are: {adata.obs_keys()}")
adata.obs_names = adata.obs[set_obs_names]
if not set_var_names == '':
if not set_var_names == "":
if set_var_names not in adata.var_keys():
raise click.UsageError(f'var {set_var_names} not found, options are: {adata.var_keys()}')
raise click.UsageError(f"var {set_var_names} not found, options are: {adata.var_keys()}")
adata.var_names = adata.var[set_var_names]
if make_obs_names_unique:
adata.obs_names_make_unique()
if make_var_names_unique:
adata.var_names_make_unique()
if not adata._obs.index.is_unique:
click.echo('Warning: obs index is not unique')
click.echo("Warning: obs index is not unique")
if not adata._var.index.is_unique:
click.echo('Warning: var index is not unique')
click.echo("Warning: var index is not unique")
return adata
@@ -90,9 +90,9 @@ def prepare(data, layout, recipe, output, plotting, sparse, overwrite,
adata.X = csc_matrix(adata.X)
def run_recipe(adata):
if recipe == 'seurat':
if recipe == "seurat":
sc.pp.recipe_seurat(adata)
elif recipe == 'zheng17':
elif recipe == "zheng17":
sc.pp.recipe_zheng17(adata)
else:
sc.pp.filter_cells(adata, min_genes=5)
@@ -104,9 +104,9 @@ def prepare(data, layout, recipe, output, plotting, sparse, overwrite,
def run_pca(adata):
if sparse:
sc.pp.pca(adata, svd_solver='arpack', zero_center=False)
sc.pp.pca(adata, svd_solver="arpack", zero_center=False)
else:
sc.pp.pca(adata, svd_solver='arpack')
sc.pp.pca(adata, svd_solver="arpack")
def run_neighbors(adata):
sc.pp.neighbors(adata)
@@ -115,46 +115,46 @@ def prepare(data, layout, recipe, output, plotting, sparse, overwrite,
sc.tl.louvain(adata)
def run_layout(adata):
if len(unique(adata.obs['louvain'].values)) < 10:
palette = 'tab10'
if len(unique(adata.obs["louvain"].values)) < 10:
palette = "tab10"
else:
palette = 'tab20'
palette = "tab20"
if 'umap' in layout:
if "umap" in layout:
sc.tl.umap(adata)
if plotting:
sc.pl.umap(adata, color='louvain', palette=palette, save='_louvain')
sc.pl.umap(adata, color="louvain", palette=palette, save="_louvain")
if 'tsne' in layout:
if "tsne" in layout:
sc.tl.tsne(adata)
if plotting:
sc.pl.tsne(adata, color='louvain', palette=palette, save='_louvain')
sc.pl.tsne(adata, color="louvain", palette=palette, save="_louvain")
def show_step(item):
names = {
'make_sparse': 'Ensuring sparsity',
'run_recipe': 'Running preprocessing recipe "%s"' % recipe,
'run_pca': 'Running PCA',
'run_neighbors': 'Calculating neighbors',
'run_louvain': 'Calculating clusters',
'run_layout': 'Computing layout'
"make_sparse": "Ensuring sparsity",
"run_recipe": f"Running preprocessing recipe \"{recipe}\"",
"run_pca": "Running PCA",
"run_neighbors": "Calculating neighbors",
"run_louvain": "Calculating clusters",
"run_layout": "Computing layout"
}
if item is not None:
return names[item.__name__]
steps = [make_sparse, run_recipe, run_pca, run_neighbors, run_louvain, run_layout]
click.echo(f'[cellxgene] Loading data from {data}, please wait...')
click.echo(f"[cellxgene] Loading data from {data}, please wait...")
adata = load_data(data)
click.echo('[cellxgene] Beginning preprocessing...')
with click.progressbar(steps, label='[cellxgene] Progress', show_eta=False, item_show_func=show_step) as bar:
click.echo("[cellxgene] Beginning preprocessing...")
with click.progressbar(steps, label="[cellxgene] Progress", show_eta=False, item_show_func=show_step) as bar:
for step in bar:
step(adata)
# saving
if not output == '':
click.echo(f'[cellxgene] Saving results to {output}...')
if not output == "":
click.echo(f"[cellxgene] Saving results to {output}...")
adata.write(output)
click.echo('[cellxgene] Success!')
click.echo("[cellxgene] Success!")
-3
View File
@@ -1,3 +0,0 @@
from app.app import app
app.run(host="0.0.0.0", debug=True, port=5005)
+2 -1
View File
@@ -34,7 +34,8 @@ class UtilTest(unittest.TestCase):
@pytest.mark.filterwarnings("ignore:Scanpy data matrix")
def test_data_type(self):
self.data.data.X = self.data.data.X.astype("float64")
self.assertWarns(UserWarning, self.data._validate_data_types())
with self.assertWarns(UserWarning):
self.data._validate_data_types()
def test_filter_idx(self):
filter_ = {