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