mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-20 11:28:47 +08:00
X float16 support (#2406)
* float16 support * fix type checks * PR review comments * add tests for custom json encoder; rename and comment for posterity * lint * typos
This commit is contained in:
committed by
Colin Megill
parent
eaae6df5e3
commit
154d099fef
@@ -1,3 +1,4 @@
|
||||
from typing import Tuple
|
||||
import numba
|
||||
import concurrent.futures
|
||||
import numpy as np
|
||||
@@ -6,7 +7,7 @@ from backend.common.constants import XApproximateDistribution
|
||||
|
||||
|
||||
@numba.njit(error_model="numpy", nogil=True)
|
||||
def min_max(arr: np.ndarray):
|
||||
def min_max_fast(arr: np.ndarray) -> Tuple[float, float]:
|
||||
"""Return (min, max) values for the ndarray."""
|
||||
|
||||
# initialize to first finite value in array. Normally,
|
||||
@@ -47,6 +48,24 @@ def min_max(arr: np.ndarray):
|
||||
return min_val, max_val
|
||||
|
||||
|
||||
def min_max_numpy(arr: np.ndarray) -> Tuple[float, float]:
|
||||
return arr.min(), arr.max()
|
||||
|
||||
|
||||
def numba_has_support_for_scalar_type(arr: np.ndarray) -> bool:
|
||||
"""Numba does not support half-floats, 128 bit floats, ints > 64 bit or non-scalars."""
|
||||
if arr.dtype == np.float32 or arr.dtype == np.float64:
|
||||
return True
|
||||
|
||||
if np.issubdtype(arr.dtype, np.integer) and arr.dtype <= np.int64:
|
||||
return True
|
||||
|
||||
if arr.dtype == np.bool_:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def estimate_approximate_distribution(X) -> XApproximateDistribution:
|
||||
"""
|
||||
Estimate the distribution (normal, count) of the X matrix.
|
||||
@@ -72,6 +91,8 @@ def estimate_approximate_distribution(X) -> XApproximateDistribution:
|
||||
else:
|
||||
raise TypeError(f"Unsupported matrix format: {str(type(X))}")
|
||||
|
||||
min_max = min_max_fast if numba_has_support_for_scalar_type(Xdata) else min_max_numpy
|
||||
|
||||
CHUNKSIZE = 1 << 24
|
||||
if Xdata.size > CHUNKSIZE:
|
||||
min_val = max_val = Xdata[0]
|
||||
|
||||
@@ -65,7 +65,13 @@ def path_join(base, *urls):
|
||||
return btpl._replace(path=path).geturl()
|
||||
|
||||
|
||||
class Float32JSONEncoder(json.JSONEncoder):
|
||||
class StrictJSONEncoder(json.JSONEncoder):
|
||||
"""
|
||||
Custom JSON encoder set-up performing two tasks:
|
||||
1. Strict JSON conformance with non-finite floats (NaN, +/-Inf) via allow_nan=False
|
||||
2. Convert various Numpy types into python types so the encoder will correctly encode.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
NaN/Infinities are illegal in standard JSON. Python extends JSON with
|
||||
@@ -78,9 +84,11 @@ class Float32JSONEncoder(json.JSONEncoder):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def default(self, obj):
|
||||
if isinstance(obj, np.float32):
|
||||
"""This helps us convert types not supported by the native JSON encoder into
|
||||
standard python types, eg, np.int64."""
|
||||
if isinstance(obj, np.floating):
|
||||
return float(obj)
|
||||
elif isinstance(obj, np.integer):
|
||||
if isinstance(obj, np.integer):
|
||||
return int(obj)
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
@@ -89,8 +97,8 @@ def custom_format_warning(msg, *args, **kwargs):
|
||||
return f"[cellxgene] Warning: {msg} \n"
|
||||
|
||||
|
||||
def jsonify_numpy(data):
|
||||
return json.dumps(data, cls=Float32JSONEncoder, allow_nan=False)
|
||||
def jsonify_strict(data):
|
||||
return json.dumps(data, cls=StrictJSONEncoder, allow_nan=False)
|
||||
|
||||
|
||||
def import_plugins(plugin_module):
|
||||
|
||||
@@ -24,7 +24,7 @@ import backend.czi_hosted.common.rest as common_rest
|
||||
from backend.common.utils.data_locator import DataLocator
|
||||
from backend.common.errors import DatasetAccessError, RequestException
|
||||
from backend.czi_hosted.common.health import health_check
|
||||
from backend.common.utils.utils import path_join, Float32JSONEncoder
|
||||
from backend.common.utils.utils import path_join, StrictJSONEncoder
|
||||
from backend.czi_hosted.data_common.matrix_loader import MatrixDataLoader
|
||||
|
||||
webbp = Blueprint("webapp", "backend.czi_hosted.common.web", template_folder="templates")
|
||||
@@ -396,7 +396,7 @@ class Server:
|
||||
self.app = Flask(__name__, static_folder=None)
|
||||
handle_api_base_url(self.app, app_config)
|
||||
self._before_adding_routes(self.app, app_config)
|
||||
self.app.json_encoder = Float32JSONEncoder
|
||||
self.app.json_encoder = StrictJSONEncoder
|
||||
server_config = app_config.server_config
|
||||
if server_config.app__server_timing_headers:
|
||||
ServerTiming(self.app, force_debug=True)
|
||||
|
||||
@@ -15,7 +15,7 @@ from backend.common.errors import (
|
||||
UnsupportedSummaryMethod,
|
||||
DatasetAccessError,
|
||||
)
|
||||
from backend.common.utils.utils import jsonify_numpy
|
||||
from backend.common.utils.utils import jsonify_strict
|
||||
from backend.common.fbs.matrix import encode_matrix_fbs
|
||||
|
||||
|
||||
@@ -336,7 +336,7 @@ class DataAdaptor(metaclass=ABCMeta):
|
||||
)
|
||||
|
||||
try:
|
||||
return jsonify_numpy(result)
|
||||
return jsonify_strict(result)
|
||||
except ValueError:
|
||||
raise JSONEncodingValueError("Error encoding differential expression to JSON")
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from flask_restful import Api, Resource
|
||||
import backend.server.common.rest as common_rest
|
||||
from backend.common.errors import DatasetAccessError, RequestException
|
||||
from backend.server.common.health import health_check
|
||||
from backend.common.utils.utils import Float32JSONEncoder
|
||||
from backend.common.utils.utils import StrictJSONEncoder
|
||||
|
||||
webbp = Blueprint("webapp", "backend.server.common.web", template_folder="templates")
|
||||
|
||||
@@ -257,7 +257,7 @@ class Server:
|
||||
def __init__(self, app_config):
|
||||
self.app = Flask(__name__, static_folder=None)
|
||||
self._before_adding_routes(self.app, app_config)
|
||||
self.app.json_encoder = Float32JSONEncoder
|
||||
self.app.json_encoder = StrictJSONEncoder
|
||||
server_config = app_config.server_config
|
||||
|
||||
# enable session data
|
||||
|
||||
@@ -232,7 +232,7 @@ class AnndataAdaptor(DataAdaptor):
|
||||
"Anndata data matrix is sparse, but not a CSC (columnar) matrix. "
|
||||
"Performance may be improved by using CSC."
|
||||
)
|
||||
if self.data.X.dtype != "float32":
|
||||
if self.data.X.dtype > np.dtype(np.float32):
|
||||
warnings.warn(
|
||||
f"Anndata data matrix is in {self.data.X.dtype} format not float32. " f"Precision may be truncated."
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ from server_timing import Timing as ServerTiming
|
||||
from backend.server.common.config.app_config import AppConfig
|
||||
from backend.common.constants import Axis, XApproximateDistribution
|
||||
from backend.common.errors import FilterError, JSONEncodingValueError, ExceedsLimitError, UnsupportedSummaryMethod
|
||||
from backend.common.utils.utils import jsonify_numpy
|
||||
from backend.common.utils.utils import jsonify_strict
|
||||
from backend.common.fbs.matrix import encode_matrix_fbs
|
||||
from backend.common.genesets import validate_gene_sets
|
||||
|
||||
@@ -331,7 +331,7 @@ class DataAdaptor(metaclass=ABCMeta):
|
||||
)
|
||||
|
||||
try:
|
||||
return jsonify_numpy(result)
|
||||
return jsonify_strict(result)
|
||||
except ValueError:
|
||||
raise JSONEncodingValueError("Error encoding differential expression to JSON")
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import unittest
|
||||
import numpy as np
|
||||
|
||||
from backend.common.utils.utils import (
|
||||
jsonify_strict,
|
||||
)
|
||||
|
||||
|
||||
class TestJsonifyStrict(unittest.TestCase):
|
||||
def test_jsonify_numpy_general_cases(self):
|
||||
self.assertEqual(jsonify_strict({}), "{}")
|
||||
self.assertEqual(jsonify_strict({"a": [], "b": "hello", "c": True}), '{"a": [], "b": "hello", "c": true}')
|
||||
|
||||
def test_jsonify_numpy_float_edges(self):
|
||||
with self.assertRaises(ValueError):
|
||||
jsonify_strict({"nan": [np.nan]})
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
jsonify_strict({"pinf": [np.PINF]})
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
jsonify_strict({"ninf": [np.NINF]})
|
||||
|
||||
def test_jsonify_numpy_ndarray(self):
|
||||
values = {
|
||||
"integer": [
|
||||
np.int8(0),
|
||||
np.int16(1),
|
||||
np.int32(2),
|
||||
np.int64(3),
|
||||
np.uint8(4),
|
||||
np.uint16(5),
|
||||
np.uint32(6),
|
||||
np.uint64(7),
|
||||
],
|
||||
"floating": [
|
||||
np.float16(100.0),
|
||||
np.float32(101.0),
|
||||
np.float64(102.0),
|
||||
],
|
||||
}
|
||||
# these just confirm our test assumptions
|
||||
self.assertTrue(isinstance(values["floating"][0], np.float16))
|
||||
self.assertTrue(isinstance(values["floating"][1], np.float32))
|
||||
self.assertTrue(isinstance(values["floating"][2], np.float64))
|
||||
self.assertTrue(isinstance(values["integer"][0], np.int8))
|
||||
self.assertTrue(isinstance(values["integer"][1], np.int16))
|
||||
self.assertTrue(isinstance(values["integer"][2], np.int32))
|
||||
self.assertTrue(isinstance(values["integer"][3], np.int64))
|
||||
self.assertTrue(isinstance(values["integer"][4], np.uint8))
|
||||
self.assertTrue(isinstance(values["integer"][5], np.uint16))
|
||||
self.assertTrue(isinstance(values["integer"][6], np.uint32))
|
||||
self.assertTrue(isinstance(values["integer"][7], np.uint64))
|
||||
# the actual test!
|
||||
self.assertEqual(
|
||||
jsonify_strict(values),
|
||||
'{"floating": [100.0, 101.0, 102.0], "integer": [0, 1, 2, 3, 4, 5, 6, 7]}',
|
||||
)
|
||||
Reference in New Issue
Block a user