Files
ray-project--ray/python/ray/data/_internal/numpy_support.py
T
2026-07-13 13:17:40 +08:00

219 lines
8.3 KiB
Python

import logging
from datetime import datetime
from typing import Any, List
import numpy as np
from ray.data._internal.tensor_extensions.utils import (
create_ragged_ndarray,
is_ndarray_like,
)
from ray.data._internal.util import _truncated_repr
logger = logging.getLogger(__name__)
def _is_valid_column_values(column_values: Any) -> bool:
"""Check whether a UDF column is valid.
Valid columns must either be a list of elements, or an array-like object.
"""
return (
isinstance(column_values, list)
or isinstance(column_values, np.ndarray)
or is_ndarray_like(column_values)
)
def _detect_highest_datetime_precision(datetime_list: List[datetime]) -> str:
"""Detect the highest precision for a list of datetime objects.
Args:
datetime_list: List of datetime objects.
Returns:
A string representing the highest precision among the datetime objects
('D', 's', 'ms', 'us', 'ns').
"""
# Define precision hierarchy
precision_hierarchy = ["D", "s", "ms", "us", "ns"]
highest_precision_index = 0 # Start with the lowest precision ("D")
for dt in datetime_list:
# Safely get the nanosecond value using getattr for backward compatibility
nanosecond = getattr(dt, "nanosecond", 0)
if nanosecond != 0:
current_precision = "ns"
elif dt.microsecond != 0:
# Check if the microsecond precision is exactly millisecond
if dt.microsecond % 1000 == 0:
current_precision = "ms"
else:
current_precision = "us"
elif dt.second != 0 or dt.minute != 0 or dt.hour != 0:
# pyarrow does not support h or m, use s for those cases to
current_precision = "s"
else:
current_precision = "D"
# Update highest_precision_index based on the hierarchy
current_index = precision_hierarchy.index(current_precision)
highest_precision_index = max(highest_precision_index, current_index)
# Stop early if highest possible precision is reached
if highest_precision_index == len(precision_hierarchy) - 1:
break
return precision_hierarchy[highest_precision_index]
def _convert_to_datetime64(dt: datetime, precision: str) -> np.datetime64:
"""
Converts a datetime object to a numpy datetime64 object with the specified
precision.
Args:
dt: A datetime object to be converted.
precision: The desired precision for the datetime64 conversion. Possible
values are 'D', 's', 'ms', 'us', 'ns'.
Returns:
np.datetime64: A numpy datetime64 object with the specified precision.
"""
if precision == "ns":
# Calculate nanoseconds from microsecond and nanosecond
microseconds_as_ns = dt.microsecond * 1000
# Use getattr for backward compatibility where nanosecond attribute may not
# exist
nanoseconds = getattr(dt, "nanosecond", 0)
total_nanoseconds = microseconds_as_ns + nanoseconds
# Create datetime64 from base datetime with microsecond precision
base_dt = np.datetime64(dt, "us")
# Add remaining nanoseconds as timedelta
return base_dt + np.timedelta64(total_nanoseconds - microseconds_as_ns, "ns")
else:
return np.datetime64(dt).astype(f"datetime64[{precision}]")
def _convert_datetime_to_np_datetime(datetime_list: List[datetime]) -> np.ndarray:
"""Convert a list of datetime objects to a NumPy array of datetime64 with nanosecond
precision.
Args:
datetime_list: A list of `datetime` objects to be converted.
Each `datetime` object represents a specific point in time.
Returns:
A NumPy array containing the `datetime64` values of the datetime
objects from the input list, with the appropriate precision (e.g., nanoseconds,
microseconds, milliseconds, etc.).
"""
# Detect the highest precision for the datetime objects
precision = _detect_highest_datetime_precision(datetime_list)
# Convert each datetime to the corresponding numpy datetime64 with the appropriate
# precision
return np.asarray([_convert_to_datetime64(dt, precision) for dt in datetime_list])
def convert_to_numpy(column_values: Any) -> np.ndarray:
"""Convert UDF columns (output of map_batches) to numpy, if possible.
This includes lists of scalars, objects supporting the array protocol, and lists
of objects supporting the array protocol, such as `[1, 2, 3]`, `Tensor([1, 2, 3])`,
and `[array(1), array(2), array(3)]`.
Args:
column_values: The column values from a UDF to attempt to convert.
Returns:
The input as an np.ndarray if possible, otherwise the original input.
Raises:
ValueError: If an input was array-like but we failed to convert it to an array.
"""
if isinstance(column_values, np.ndarray):
# No copy/conversion needed, just keep it verbatim.
return column_values
elif isinstance(column_values, list):
if len(column_values) == 1 and isinstance(column_values[0], np.ndarray):
# Optimization to avoid conversion overhead from list to np.array.
return np.expand_dims(column_values[0], axis=0)
if all(isinstance(elem, datetime) for elem in column_values):
return _convert_datetime_to_np_datetime(column_values)
# Try to convert list values into an numpy array via
# np.array(), so users don't need to manually cast.
# NOTE: we don't cast generic iterables, since types like
# `str` are also Iterable.
try:
# Convert array-like objects (like torch.Tensor) to `np.ndarray`s
if all(is_ndarray_like(e) for e in column_values):
# Use `np.asarray` instead of `np.array` to avoid copying if possible.
column_values = [np.asarray(e) for e in column_values]
shapes = set()
has_object = False
for e in column_values:
if isinstance(e, np.ndarray):
shapes.add((e.dtype, e.shape))
elif isinstance(e, bytes):
# Don't convert variable length binary data to Numpy arrays as it
# treats zero encoding as termination by default.
# Per recommendation from
# https://github.com/apache/arrow/issues/26470,
# we use object dtype.
# https://github.com/ray-project/ray/issues/35586#issuecomment-1558148261
has_object = True
elif not np.isscalar(e):
has_object = True
# When column values are
# - Arrays of heterogeneous shapes
# - Byte-strings (viewed as arrays of heterogeneous shapes)
# - Non-scalar objects (tuples, lists, arbitrary object types)
#
# Custom "ragged ndarray" is created, represented as an array of
# references (ie ndarray with dtype=object)
if has_object or len(shapes) > 1:
# This util works around some limitations of np.array(dtype=object).
return create_ragged_ndarray(column_values)
else:
return np.asarray(column_values)
except Exception as e:
logger.error(
f"Failed to convert column values to numpy array: "
f"{_truncated_repr(column_values)}",
exc_info=e,
)
raise ValueError(
"Failed to convert column values to numpy array: "
f"({_truncated_repr(column_values)}): {e}."
) from e
elif is_ndarray_like(column_values):
# Converts other array-like objects such as torch.Tensor.
try:
# Use `np.asarray` instead of `np.array` to avoid copying if possible.
return np.asarray(column_values)
except Exception as e:
logger.error(
f"Failed to convert column values to numpy array: "
f"{_truncated_repr(column_values)}",
exc_info=e,
)
raise ValueError(
"Failed to convert column values to numpy array: "
f"({_truncated_repr(column_values)}): {e}."
) from e
else:
return column_values