[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/feast-dev/feast/pytorch-template/sdk/python/feast/type_map.py [Back]  [Original]

# Copyright 2019 The Feast Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import decimal
import json
import logging
from collections import defaultdict
from datetime import datetime, timezone
from typing import (
    TYPE_CHECKING,
    Any,
    Dict,
    Iterator,
    List,
    Optional,
    Sequence,
    Set,
    Sized,
    Tuple,
    Type,
    Union,
    cast,
)

import numpy as np
import pandas as pd
from google.protobuf.timestamp_pb2 import Timestamp

from feast.protos.feast.types.Value_pb2 import (
    BoolList,
    BytesList,
    DoubleList,
    FloatList,
    Int32List,
    Int64List,
    Map,
    MapList,
    StringList,
)
from feast.protos.feast.types.Value_pb2 import Value as ProtoValue
from feast.value_type import ListType, ValueType

if TYPE_CHECKING:
    import pyarrow

# null timestamps get converted to -9223372036854775808
NULL_TIMESTAMP_INT_VALUE: int = np.datetime64("NaT").astype(int)

logger = logging.getLogger(__name__)


def feast_value_type_to_python_type(field_value_proto: ProtoValue) -> Any:
    """
    Converts field value Proto to Dict and returns each field's Feast Value Type value
    in their respective Python value.

    Args:
        field_value_proto: Field value Proto

    Returns:
        Python native type representation/version of the given field_value_proto
    """
    val_attr = field_value_proto.WhichOneof("val")
    if val_attr is None:
        return None
    val = getattr(field_value_proto, val_attr)

    # Handle Map and MapList types FIRST (before generic list processing)
    if val_attr == "map_val":
        return _handle_map_value(val)
    elif val_attr == "map_list_val":
        return _handle_map_list_value(val)

    # If it's a _LIST type extract the list.
    if hasattr(val, "val"):
        val = list(val.val)

    # Convert UNIX_TIMESTAMP values to `datetime`
    if val_attr == "unix_timestamp_list_val":
        val = [
            (
                datetime.fromtimestamp(v, tz=timezone.utc)
                if v != NULL_TIMESTAMP_INT_VALUE
                else None
            )
            for v in val
        ]
    elif val_attr == "unix_timestamp_val":
        val = (
            datetime.fromtimestamp(val, tz=timezone.utc)
            if val != NULL_TIMESTAMP_INT_VALUE
            else None
        )

    return val


def _handle_map_value(map_message) -> Dict[str, Any]:
    """Handle Map proto message containing map val."""
    result = {}

    for key, value in map_message.val.items():
        # Recursively handle the Value message
        result[key] = feast_value_type_to_python_type(value)

    return result


def _handle_map_list_value(map_list_message) -> List[Dict[str, Any]]:
    """Handle MapList proto message containing repeated Map val."""
    result = []

    for map_item in map_list_message.val:
        # Handle each Map in the list
        processed_map = _handle_map_value(map_item)
        result.append(processed_map)

    return result


def feast_value_type_to_pandas_type(value_type: ValueType) -> Any:
    value_type_to_pandas_type: Dict[ValueType, str] = {
        ValueType.FLOAT: "float",
        ValueType.INT32: "int",
        ValueType.INT64: "int",
        ValueType.STRING: "str",
        ValueType.DOUBLE: "float",
        ValueType.BYTES: "bytes",
        ValueType.BOOL: "bool",
        ValueType.UNIX_TIMESTAMP: "datetime64[ns]",
    }
    if value_type.name == "MAP" or value_type.name.endswith("_LIST"):
        return "object"
    if value_type in value_type_to_pandas_type:
        return value_type_to_pandas_type[value_type]
    raise TypeError(
        f"Casting to pandas type for type {value_type} failed. "
        f"Type {value_type} not found"
    )


def python_type_to_feast_value_type(
    name: str,
    value: Optional[Any] = None,
    recurse: bool = True,
    type_name: Optional[str] = None,
) -> ValueType:
    """
    Finds the equivalent Feast Value Type for a Python value. Both native
    and Pandas types are supported. This function will recursively look
    for nested types when arrays are detected. All types must be homogenous.

    Args:
        name: Name of the value or field
        value: Value that will be inspected
        recurse: Whether to recursively look for nested types in arrays

    Returns:
        Feast Value Type
    """
    type_name = (type_name or type(value).__name__).lower()

    type_map = {
        "int": ValueType.INT64,
        "str": ValueType.STRING,
        "string": ValueType.STRING,  # pandas.StringDtype
        "float": ValueType.DOUBLE,
        "bytes": ValueType.BYTES,
        "float64": ValueType.DOUBLE,
        "float32": ValueType.FLOAT,
        "int64": ValueType.INT64,
        "uint64": ValueType.INT64,
        "int32": ValueType.INT32,
        "uint32": ValueType.INT32,
        "int16": ValueType.INT32,
        "uint16": ValueType.INT32,
        "uint8": ValueType.INT32,
        "int8": ValueType.INT32,
        "bool_": ValueType.BOOL,  # np.bool_
        "bool": ValueType.BOOL,
        "boolean": ValueType.BOOL,
        "timedelta": ValueType.UNIX_TIMESTAMP,
        "timestamp": ValueType.UNIX_TIMESTAMP,
        "datetime": ValueType.UNIX_TIMESTAMP,
        "datetime64[ns]": ValueType.UNIX_TIMESTAMP,
        "datetime64[ns, tz]": ValueType.UNIX_TIMESTAMP,  # special dtype of pandas
        "datetime64[ns, utc]": ValueType.UNIX_TIMESTAMP,
        "date": ValueType.UNIX_TIMESTAMP,
        "category": ValueType.STRING,
    }

    if type_name in type_map:
        return type_map[type_name]

    # Handle pandas "object" dtype by inspecting the actual value
    if type_name == "object" and value is not None:
        # Check the actual type of the value
        actual_type = type(value).__name__.lower()
        if actual_type == "str":
            return ValueType.STRING
        # Check if it's a dictionary (could be a Map)
        elif actual_type == "dict":
            return ValueType.MAP
        # If it's a different type wrapped in object, try to infer from the value
        elif actual_type in type_map:
            return type_map[actual_type]

    if isinstance(value, np.ndarray) and str(value.dtype) in type_map:
        item_type = type_map[str(value.dtype)]
        return ValueType[item_type.name + "_LIST"]

    if isinstance(value, (list, np.ndarray)):
        # Check if it's a list of maps
        if value and isinstance(value[0], dict):
            return ValueType.MAP_LIST
        # if the value's type is "ndarray" and we couldn't infer from "value.dtype"
        # this is most probably array of "object",
        # so we need to iterate over objects and try to infer type of each item
        if not recurse:
            raise ValueError(
                f"Value type for field {name} is {type(value)} but "
                f"recursion is not allowed. Array types can only be one level "
                f"deep."
            )

        # This is the final type which we infer from the list
        common_item_value_type = None
        for item in value:
            if isinstance(item, ProtoValue):
                current_item_value_type: ValueType = _proto_value_to_value_type(item)
            else:
                # Get the type from the current item, only one level deep
                current_item_value_type = python_type_to_feast_value_type(
                    name=name, value=item, recurse=False
                )
            # Validate whether the type stays consistent
            if (
                common_item_value_type
                and not common_item_value_type == current_item_value_type
            ):
                raise ValueError(
                    f"List value type for field {name} is inconsistent. "
                    f"{common_item_value_type} different from "
                    f"{current_item_value_type}."
                )
            common_item_value_type = current_item_value_type
        if common_item_value_type is None:
            return ValueType.UNKNOWN
        return ValueType[common_item_value_type.name + "_LIST"]

    # Check if it's a dictionary (Map type)
    if isinstance(value, dict):
        return ValueType.MAP

    raise ValueError(
        f"Value with native type {type_name} cannot be converted into Feast value type"
    )


def python_values_to_feast_value_type(
    name: str, values: Any, recurse: bool = True
) -> ValueType:
    inferred_dtype = ValueType.UNKNOWN
    for row in values:
        current_dtype = python_type_to_feast_value_type(
            name, value=row, recurse=recurse
        )

        if inferred_dtype is ValueType.UNKNOWN:
            inferred_dtype = current_dtype
        else:
            if current_dtype != inferred_dtype and current_dtype not in (
                ValueType.UNKNOWN,
                ValueType.NULL,
            ):
                raise TypeError(
                    f"Input entity {name} has mixed types, {current_dtype} and {inferred_dtype}. That is not allowed. "
                )
    if inferred_dtype in (ValueType.UNKNOWN, ValueType.NULL):
        raise ValueError(
            f"field {name} cannot have all null values for type inference."
        )

    return inferred_dtype


def _convert_value_type_str_to_value_type(type_str: str) -> ValueType:
    type_map = {
        "UNKNOWN": ValueType.UNKNOWN,
        "BYTES": ValueType.BYTES,
        "STRING": ValueType.STRING,
        "INT32": ValueType.INT32,
        "INT64": ValueType.INT64,
        "DOUBLE": ValueType.DOUBLE,
        "FLOAT": ValueType.FLOAT,
        "FLOAT32": ValueType.FLOAT,
        "BOOL": ValueType.BOOL,
        "NULL": ValueType.NULL,
        "UNIX_TIMESTAMP": ValueType.UNIX_TIMESTAMP,
        "BYTES_LIST": ValueType.BYTES_LIST,
        "STRING_LIST": ValueType.STRING_LIST,
        "INT32_LIST ": ValueType.INT32_LIST,
        "INT64_LIST": ValueType.INT64_LIST,
        "DOUBLE_LIST": ValueType.DOUBLE_LIST,
        "FLOAT_LIST": ValueType.FLOAT_LIST,
        "BOOL_LIST": ValueType.BOOL_LIST,
        "UNIX_TIMESTAMP_LIST": ValueType.UNIX_TIMESTAMP_LIST,
    }
    return type_map.get(type_str, ValueType.STRING)


def _type_err(item, dtype):
    raise TypeError(f'Value "{item}" is of type {type(item)} not of type {dtype}')


PYTHON_LIST_VALUE_TYPE_TO_PROTO_VALUE: Dict[
    ValueType, Tuple[ListType, str, List[Type]]
] = {
    ValueType.FLOAT_LIST: (
        FloatList,
        "float_list_val",
        [np.float32, np.float64, float],
    ),
    ValueType.DOUBLE_LIST: (
        DoubleList,
        "double_list_val",
        [np.float64, np.float32, float],
    ),
    ValueType.INT32_LIST: (Int32List, "int32_list_val", [np.int64, np.int32, int]),
    ValueType.INT64_LIST: (Int64List, "int64_list_val", [np.int64, np.int32, int]),
    ValueType.UNIX_TIMESTAMP_LIST: (
        Int64List,
        "int64_list_val",
        [np.datetime64, np.int64, np.int32, int, datetime, Timestamp],
    ),
    ValueType.STRING_LIST: (StringList, "string_list_val", [np.str_, str]),
    ValueType.BOOL_LIST: (BoolList, "bool_list_val", [np.bool_, bool]),
    ValueType.BYTES_LIST: (BytesList, "bytes_list_val", [np.bytes_, bytes]),
}

PYTHON_SCALAR_VALUE_TYPE_TO_PROTO_VALUE: Dict[
    ValueType, Tuple[str, Any, Optional[Set[Type]]]
] = {
    ValueType.INT32: ("int32_val", lambda x: int(x), None),
    ValueType.INT64: (
        "int64_val",
        lambda x: (
            int(x.timestamp())
            if isinstance(x, pd._libs.tslibs.timestamps.Timestamp)
            else int(x)
        ),
        None,
    ),
    ValueType.FLOAT: ("float_val", lambda x: float(x), None),
    ValueType.DOUBLE: (
        "double_val",
        lambda x: x,
        {float, np.float64, int, np.int_, decimal.Decimal},
    ),
    ValueType.STRING: ("string_val", lambda x: str(x), None),
    ValueType.BYTES: ("bytes_val", lambda x: x, {bytes}),
    ValueType.IMAGE_BYTES: ("bytes_val", lambda x: x, {bytes}),
    ValueType.BOOL: ("bool_val", lambda x: x, {bool, np.bool_, int, np.int_}),
}


def _python_datetime_to_int_timestamp(
    values: Sequence[Any],
) -> Sequence[Union[int, np.int_]]:
    # Fast path for Numpy array.
    if isinstance(values, np.ndarray) and isinstance(values.dtype, np.datetime64):
        if values.ndim != 1:
            raise ValueError("Only 1 dimensional arrays are supported.")
        return cast(Sequence[np.int_], values.astype("datetime64[s]").astype(np.int_))

    int_timestamps = []
    for value in values:
        if isinstance(value, datetime):
            int_timestamps.append(int(value.timestamp()))
        elif isinstance(value, Timestamp):
            int_timestamps.append(int(value.ToSeconds()))
        elif isinstance(value, np.datetime64):
            int_timestamps.append(value.astype("datetime64[s]").astype(np.int_))  # type: ignore[attr-defined]
        elif isinstance(value, type(np.nan)):
            int_timestamps.append(NULL_TIMESTAMP_INT_VALUE)
        else:
            int_timestamps.append(int(value))
    return int_timestamps


def _python_value_to_proto_value(
    feast_value_type: ValueType, values: List[Any]
) -> List[ProtoValue]:
    """
    Converts a Python (native, pandas) value to a Feast Proto Value based
    on a provided value type

    Args:
        feast_value_type: The target value type
        values: List of Values that will be converted

    Returns:
        List of Feast Value Proto
    """
    # Handle Map and MapList types first
    if feast_value_type == ValueType.MAP:
        return [
            ProtoValue(map_val=_python_dict_to_map_proto(value))
            if value is not None
            else ProtoValue()
            for value in values
        ]

    if feast_value_type == ValueType.MAP_LIST:
        return [
            ProtoValue(map_list_val=_python_list_to_map_list_proto(value))
            if value is not None
            else ProtoValue()
            for value in values
        ]

    # ToDo: make a better sample for type checks (more than one element)
    sample = next(filter(_non_empty_value, values), None)  # first not empty value

    # Detect list type and handle separately
    if "list" in feast_value_type.name.lower():
        # Feature can be list but None is still valid
        if feast_value_type in PYTHON_LIST_VALUE_TYPE_TO_PROTO_VALUE:
            proto_type, field_name, valid_types = PYTHON_LIST_VALUE_TYPE_TO_PROTO_VALUE[
                feast_value_type
            ]

            # Bytes to array type conversion
            if isinstance(sample, (bytes, bytearray)):
                # Bytes of an array containing elements of bytes not supported
                if feast_value_type == ValueType.BYTES_LIST:
                    raise _type_err(sample, ValueType.BYTES_LIST)

                json_sample = json.loads(sample)
                if isinstance(json_sample, list):
                    json_values = [json.loads(value) for value in values]
                    if feast_value_type == ValueType.BOOL_LIST:
                        json_values = [
                            [bool(item) for item in list_item]
                            for list_item in json_values
                        ]
                    return [
                        ProtoValue(**{field_name: proto_type(val=v)})  # type: ignore
                        for v in json_values
                    ]
                raise _type_err(sample, valid_types[0])

            if sample is not None and not all(
                type(item) in valid_types for item in sample
            ):
                # to_numpy() in utils._convert_arrow_to_proto() upcasts values of type Array of INT32 or INT64 with NULL values to Float64 automatically.
                for item in sample:
                    if type(item) not in valid_types:
                        if feast_value_type in [
                            ValueType.INT32_LIST,
                            ValueType.INT64_LIST,
                        ]:
                            if not any(np.isnan(item) for item in sample):
                                logger.error(
                                    "Array of Int32 or Int64 type has NULL values. to_numpy() upcasts to Float64 automatically."
                                )
                        raise _type_err(item, valid_types[0])

            if feast_value_type == ValueType.UNIX_TIMESTAMP_LIST:
                return [
                    (
                        # ProtoValue does actually accept `np.int_` but the typing complains.
                        ProtoValue(
                            unix_timestamp_list_val=Int64List(
                                val=_python_datetime_to_int_timestamp(value)  # type: ignore
                            )
                        )
                        if value is not None
                        else ProtoValue()
                    )
                    for value in values
                ]
            if feast_value_type == ValueType.BOOL_LIST:
                # ProtoValue does not support conversion of np.bool_ so we need to convert it to support np.bool_.
                return [
                    (
                        ProtoValue(
                            **{field_name: proto_type(val=[bool(e) for e in value])}  # type: ignore
                        )
                        if value is not None
                        else ProtoValue()
                    )
                    for value in values
                ]
            return [
                (
                    ProtoValue(**{field_name: proto_type(val=value)})  # type: ignore
                    if value is not None
                    else ProtoValue()
                )
                for value in values
            ]

    # Handle scalar types below
    else:
        if sample is None:
            # all input values are None
            return [ProtoValue()] * len(values)

        if feast_value_type == ValueType.UNIX_TIMESTAMP:
            int_timestamps = _python_datetime_to_int_timestamp(values)
            # ProtoValue does actually accept `np.int_` but the typing complains.
            return [ProtoValue(unix_timestamp_val=ts) for ts in int_timestamps]  # type: ignore

        (
            field_name,
            func,
            valid_scalar_types,
        ) = PYTHON_SCALAR_VALUE_TYPE_TO_PROTO_VALUE[feast_value_type]
        if valid_scalar_types:
            if (sample == 0 or sample == 0.0) and feast_value_type != ValueType.BOOL:
                # Numpy convert 0 to int. However, in the feature view definition, the type of column may be a float.
                # So, if value is 0, type validation must pass if scalar_types are either int or float.
                allowed_types = {np.int64, int, np.float64, float, decimal.Decimal}
                assert type(sample) in allowed_types, (
                    f"Type `{type(sample)}` not in {allowed_types}"
                )
            else:
                assert type(sample) in valid_scalar_types, (
                    f"Type `{type(sample)}` not in {valid_scalar_types}"
                )
        if feast_value_type == ValueType.BOOL:
            # ProtoValue does not support conversion of np.bool_ so we need to convert it to support np.bool_.
            return [
                (
                    ProtoValue(
                        **{
                            field_name: func(
                                bool(value) if type(value) is np.bool_ else value  # type: ignore
                            )
                        }
                    )
                    if not pd.isnull(value)
                    else ProtoValue()
                )
                for value in values
            ]
        if feast_value_type in PYTHON_SCALAR_VALUE_TYPE_TO_PROTO_VALUE:
            out = []
            for value in values:
                if isinstance(value, ProtoValue):
                    out.append(value)
                elif not pd.isnull(value):
                    out.append(ProtoValue(**{field_name: func(value)}))
                else:
                    out.append(ProtoValue())
            return out

    raise Exception(f"Unsupported data type: ${str(type(values[0]))}")


def _python_dict_to_map_proto(python_dict: Dict[str, Any]) -> Map:
    """Convert a Python dictionary to a Map proto message."""
    map_proto = Map()
    for key, value in python_dict.items():
        # Handle None values explicitly
        if value is None:
            map_proto.val[key].CopyFrom(
                ProtoValue()
            )  # Empty ProtoValue represents None
            continue

        if isinstance(value, dict):
            # Nested map
            nested_map_proto = _python_dict_to_map_proto(value)
            map_proto.val[key].CopyFrom(ProtoValue(map_val=nested_map_proto))
        elif isinstance(value, list) and value and isinstance(value[0], dict):
            # List of maps (MapList)
            map_list_proto = _python_list_to_map_list_proto(value)
            map_proto.val[key].CopyFrom(ProtoValue(map_list_val=map_list_proto))
        else:
            # Handle scalar values and regular lists
            # Let python_values_to_proto_values infer the type
            proto_values = python_values_to_proto_values([value], ValueType.UNKNOWN)
            map_proto.val[key].CopyFrom(proto_values[0])
    return map_proto


def _python_list_to_map_list_proto(python_list: List[Dict[str, Any]]) -> MapList:
    """Convert a Python list of dictionaries to a MapList proto message."""
    map_list_proto = MapList()

    for item in python_list:
        if isinstance(item, dict):
            map_proto = _python_dict_to_map_proto(item)
            map_list_proto.val.append(map_proto)
        else:
            raise ValueError(f"MapList can only contain dictionaries, got {type(item)}")

    return map_list_proto


def python_values_to_proto_values(
    values: List[Any], feature_type: ValueType = ValueType.UNKNOWN
) -> List[ProtoValue]:
    value_type = feature_type
    sample = next(filter(_non_empty_value, values), None)  # first not empty value
    if sample is not None and feature_type == ValueType.UNKNOWN:
        if isinstance(sample, (list, np.ndarray)):
            value_type = (
                feature_type
                if len(sample) == 0
                else python_type_to_feast_value_type("", sample)
            )
        else:
            value_type = python_type_to_feast_value_type("", sample)

    if value_type == ValueType.UNKNOWN:
        raise TypeError("Couldn't infer value type from empty value")

    proto_values = _python_value_to_proto_value(value_type, values)

    if len(proto_values) != len(values):
        raise ValueError(
            f"Number of proto values {len(proto_values)} does not match number of values {len(values)}"
        )

    return proto_values


PROTO_VALUE_TO_VALUE_TYPE_MAP: Dict[str, ValueType] = {
    "int32_val": ValueType.INT32,
    "int64_val": ValueType.INT64,
    "double_val": ValueType.DOUBLE,
    "float_val": ValueType.FLOAT,
    "string_val": ValueType.STRING,
    "bytes_val": ValueType.BYTES,
    "bool_val": ValueType.BOOL,
    "int32_list_val": ValueType.INT32_LIST,
    "int64_list_val": ValueType.INT64_LIST,
    "double_list_val": ValueType.DOUBLE_LIST,
    "float_list_val": ValueType.FLOAT_LIST,
    "string_list_val": ValueType.STRING_LIST,
    "bytes_list_val": ValueType.BYTES_LIST,
    "bool_list_val": ValueType.BOOL_LIST,
    "map_val": ValueType.MAP,
    "map_list_val": ValueType.MAP_LIST,
}

VALUE_TYPE_TO_PROTO_VALUE_MAP: Dict[ValueType, str] = {
    v: k for k, v in PROTO_VALUE_TO_VALUE_TYPE_MAP.items()
}


def _proto_value_to_value_type(proto_value: ProtoValue) -> ValueType:
    """
    Returns Feast ValueType given Feast ValueType string.

    Args:
        proto_str: str

    Returns:
        A variant of ValueType.
    """
    proto_str = proto_value.WhichOneof("val")
    if proto_str is None:
        return ValueType.UNKNOWN
    return PROTO_VALUE_TO_VALUE_TYPE_MAP[proto_str]


def pa_to_feast_value_type(pa_type_as_str: str) -> ValueType:
    is_list = False
    if pa_type_as_str.startswith("list

Web Proxy Viewer  |  New URL  |  Original Page