Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/user-guide/content-type.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,57 @@ raw_response = response.json()
inference_response = InferenceResponse(**raw_response)
```

#### Support for NaN values

The NaN (Not a Number) value is used in Numpy and other scientific libraries to
describe an invalid or missing value (e.g. a division by zero).
In some scenarios, it may be desirable to let your models receive and / or
output NaN values (e.g. these can be useful sometimes with GBTs, like XGBoost
models).
This is why MLServer supports encoding NaN values on your request / response
payloads under some conditions.

In order to send / receive NaN values, you must ensure that:

- You are using the `REST` interface.
- The input / output entry containing NaN values uses either the `FP16`, `FP32`
or `FP64` datatypes.
- You are either using the [Pandas codec](#pandas-dataframe) or the [Numpy
codec](#numpy-array).

Assuming those conditions are satisfied, any `null` value within your tensor
payload will be converted to NaN.

For example, if you take the following Numpy array:

```python
import numpy as np

foo = np.array([[1.2, 2.3], [np.NaN, 4.5]])
```

We could encode it as:

```{code-block} json
---
emphasize-lines: 8
---
{
"inputs": [
{
"name": "foo",
"parameters": {
"content_type": "np"
},
"data": [1, 2, null, 4]
Comment thread
adriangonz marked this conversation as resolved.
"datatype": "FP64",
"shape": [2, 2],
}
]
}
```


### Model Metadata

Content types can also be defined as part of the [model's
Expand Down
19 changes: 18 additions & 1 deletion mlserver/codecs/numpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,24 @@ def _encode_data(data: np.ndarray, datatype: str) -> list:
# need to encapsulate it into a list so that it's compatible.
return [data.tobytes()]

return data.flatten().tolist()
flattened_list = data.flatten().tolist()

# Replace NaN with null
if datatype != "BYTES":
# The `isnan` method doesn't work on Numpy arrays with non-numeric
# types
has_nan = np.isnan(data).any()
if has_nan:
flattened_list = list(map(convert_nan, flattened_list))

return flattened_list


def convert_nan(val):
Comment thread
adriangonz marked this conversation as resolved.
if np.isnan(val):
return None

return val


@register_input_codec
Expand Down
9 changes: 7 additions & 2 deletions mlserver/codecs/pandas.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Optional, Any, List, Tuple

from .base import RequestCodec, register_request_codec
from .numpy import to_datatype, to_dtype
from .numpy import to_datatype, to_dtype, convert_nan
from .string import encode_str, StringCodec
from .utils import get_decoded_or_raw, InputOrOutput, inject_batch_dimension
from .lists import ListElement
Expand Down Expand Up @@ -35,8 +35,13 @@ def _to_series(input_or_output: InputOrOutput) -> pd.Series:
def _to_response_output(series: pd.Series, use_bytes: bool = True) -> ResponseOutput:
datatype = to_datatype(series.dtype)
data = series.tolist()
content_type = None

# Replace NaN with null
has_nan = series.isnull().any()
if has_nan:
data = list(map(convert_nan, data))

content_type = None
if datatype == "BYTES":
data, content_type = _process_bytes(data, use_bytes)

Expand Down
26 changes: 25 additions & 1 deletion tests/codecs/test_numpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,26 @@ def test_can_encode(payload: Any, expected: bool):
parameters=Parameters(content_type=NumpyCodec.ContentType),
),
),
(
np.array([None, "bar"]),
ResponseOutput(
name="foo",
shape=[2, 1],
data=[None, "bar"],
datatype="BYTES",
parameters=Parameters(content_type=NumpyCodec.ContentType),
),
),
(
np.array([2.3, 3.4, np.NaN]),
ResponseOutput(
name="foo",
shape=[3, 1],
data=[2.3, 3.4, None],
datatype="FP64",
parameters=Parameters(content_type=NumpyCodec.ContentType),
),
),
],
)
def test_encode_output(payload: np.ndarray, expected: ResponseOutput):
Expand Down Expand Up @@ -110,6 +130,10 @@ def test_encode_output(payload: np.ndarray, expected: ResponseOutput):
RequestInput(name="foo", shape=[2], data=["foo", "bar"], datatype="BYTES"),
np.array(["foo", "bar"], dtype=str),
),
(
RequestInput(name="foo", shape=[3], data=[1, 2, None], datatype="FP16"),
np.array([1, 2, np.NaN], dtype="float16"),
Comment thread
adriangonz marked this conversation as resolved.
),
],
)
def test_decode_input(request_input: RequestInput, expected: np.ndarray):
Expand All @@ -127,7 +151,7 @@ def test_decode_input(request_input: RequestInput, expected: np.ndarray):
RequestInput(name="foo", shape=[2], data=["foo", "bar"], datatype="BYTES"),
],
)
def test_encode_input(request_input):
def test_codec_idempotent(request_input: RequestInput):
decoded = NumpyCodec.decode_input(request_input)
response_output = NumpyCodec.encode_output(name="foo", payload=decoded)

Expand Down
27 changes: 27 additions & 0 deletions tests/codecs/test_pandas.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ def test_can_encode(payload: Any, expected: bool):
name="bar", shape=[2, 1], data=[[1, 2, 3], [4, 5, 6]], datatype="BYTES"
),
),
(
pd.Series(data=[4, np.NaN, 6], name="bar"),
Comment thread
adriangonz marked this conversation as resolved.
True,
ResponseOutput(
name="bar",
shape=[3, 1],
data=[4, None, 6],
datatype="FP64",
),
),
],
)
def test_to_response_output(series, use_bytes, expected):
Expand Down Expand Up @@ -134,6 +144,23 @@ def test_to_response_output(series, use_bytes, expected):
],
),
),
(
pd.DataFrame(
{
"a": [1, np.NaN, 3],
Comment thread
adriangonz marked this conversation as resolved.
}
),
False,
InferenceResponse(
model_name="my-model",
parameters=Parameters(content_type=PandasCodec.ContentType),
outputs=[
ResponseOutput(
name="a", shape=[3, 1], datatype="FP64", data=[1, None, 3]
)
],
),
),
],
)
def test_encode_response(dataframe, use_bytes, expected):
Expand Down