Source code for static_frame.core.reduce

from __future__ import annotations

from functools import partial
from itertools import repeat

import numpy as np
import typing_extensions as tp
from arraykit import factorize, group_reduce, resolve_dtype

from static_frame.core.frame import Frame
from static_frame.core.index_auto import IndexAutoFactory, TIndexAutoFactory
from static_frame.core.index_base import IndexBase
from static_frame.core.node_selector import Interface, InterfaceBatch
from static_frame.core.series import Series
from static_frame.core.type_blocks import TypeBlocks
from static_frame.core.util import (
    DTYPE_FLOAT_DEFAULT,
    DTYPE_OBJECT,
    EMPTY_ARRAY,
    FRAME_INITIALIZER_DEFAULT,
    NULL_SLICE,
    IterNodeType,
    TCallableAny,
    TILocSelectorOne,
    TIndexCtorSpecifier,
    TIndexInitializer,
    TLabel,
    TMpContext,
    TName,
    TUFunc,
    concat_resolved,
    get_concurrent_executor,
    iterable_to_array_1d,
    ufunc_dtype_to_dtype,
)

if tp.TYPE_CHECKING:
    from static_frame.core.batch import Batch
    from static_frame.core.generic_aliases import TFrameAny

TNDArrayAny = np.ndarray[tp.Any, tp.Any]
TDtypeAny = np.dtype[tp.Any]

TFrameOrSeries = tp.Union[Frame, Series]
TFrameOrArray = tp.Union[Frame, TNDArrayAny]
TIterableFrameItems = tp.Iterable[tp.Tuple[TLabel, TFrameOrArray]]
TShape2D = tp.Tuple[int, int]

TListILocToFunc = tp.List[tp.Tuple[TILocSelectorOne, TUFunc]]
TListLabelToFunc = tp.List[tp.Tuple[TLabel, TUFunc]]

# Reduction functions whose per-group result an `arraykit.group_reduce` op reproduces
# exactly. Only functions proven equivalent are listed; anything else (nan-variants,
# mean/std, lambdas, ...) is absent and routes through the general per-group loop. Keyed
# by function identity, so the vectorized fast path is opt-in and never changes results.
_REDUCE_FUNC_TO_OP: tp.Dict[tp.Any, str] = {
    np.sum: 'sum',
    np.prod: 'prod',
    np.min: 'min',
    np.max: 'max',
    len: 'count',
    sum: 'sum',
    min: 'min',
    max: 'max',
}

# the platform default integer (C long): int32 on Windows, int64 elsewhere. The loop
# infers this width for value-derived integer results (object components, count), and
# numpy accumulates narrow-int sum/prod at this width -- so the fast path must match it
# rather than arraykit.group_reduce's fixed int64.
_DTYPE_INT_PLATFORM = np.dtype(np.int_)
_DTYPE_UINT_PLATFORM = np.dtype(np.uint)


def _numpy_int_accum_dtype(dtype: TDtypeAny) -> TDtypeAny:
    """The dtype numpy accumulates ``sum``/``prod`` of an integer array in: the platform
    default int (matching signedness) when the input is narrower, else the input dtype."""
    plat = _DTYPE_UINT_PLATFORM if dtype.kind == 'u' else _DTYPE_INT_PLATFORM
    return np.promote_types(dtype, plat)


def _reduce_column_plan(
    func: TUFunc,
    op: str,
    values: TNDArrayAny,
    unified: TDtypeAny | None,
) -> tp.Optional[tp.Tuple[TNDArrayAny, TDtypeAny]]:
    """
    Determine what to feed ``group_reduce`` for one column and the resulting dtype, or
    None to fall back to the loop.

    ``unified`` is None for iter_group (reduce each column at its native dtype) or the
    per-group 2D component's unified dtype for iter_group_array.
    """
    dt = values.dtype
    if op == 'count':  # group sizes: the loop infers the platform default int
        return values, _DTYPE_INT_PLATFORM
    if unified is None:  # iter_group: native per-column dtype
        post_dt = ufunc_dtype_to_dtype(func, dt)
        if post_dt is None:
            return None
        return values, post_dt
    if unified == DTYPE_OBJECT:
        # iter_group_array on a mixed frame: the object 2D component reduces via Python
        # numbers, so the loop's value-inferred output is the platform default int for
        # integer columns and float64 for float (narrow widths erased by object boxing)
        if dt.kind in 'iu':
            return values, _DTYPE_INT_PLATFORM
        if dt.kind == 'f':
            return values.astype(DTYPE_FLOAT_DEFAULT), DTYPE_FLOAT_DEFAULT
        return None  # a non-numeric column in an object component
    # a numeric unified dtype: reduce each column cast to it (as the 2D array does)
    post_dt = ufunc_dtype_to_dtype(func, unified)
    if post_dt is None:
        return None
    acc = values if dt == unified else values.astype(unified)
    return acc, post_dt


class PoolConfig(tp.NamedTuple):
    """Concurrency configuration for a pooled reduction (see ``reduce_pool``)."""

    max_workers: tp.Optional[int] = None
    chunksize: int = 1
    use_threads: bool = False
    mp_context: TMpContext = None


def _reduce_row_worker(
    iloc_to_func: TListILocToFunc,
    is_array: bool,
    yield_type: IterNodeType,
    n_cols: int,
    label_component: tp.Tuple[TLabel, TFrameOrArray],
) -> tp.List[tp.Any]:
    """
    Reduce one group's component to a list of per-column scalars. Defined at module
    level (picklable) so a process pool can dispatch it; a thread pool calls it directly.
    Matches the sequential ``_get_blocks`` application (column view per iloc).
    """
    label, component = label_component
    row: tp.List[tp.Any] = [None] * n_cols
    if is_array:
        if yield_type == IterNodeType.VALUES:
            for i, (iloc, func) in enumerate(iloc_to_func):
                row[i] = func(component[NULL_SLICE, iloc])
        else:
            for i, (iloc, func) in enumerate(iloc_to_func):
                row[i] = func(label, component[NULL_SLICE, iloc])
    else:  # component is a Frame
        if yield_type == IterNodeType.VALUES:
            for i, (iloc, func) in enumerate(iloc_to_func):
                row[i] = func(component._blocks._extract_array_column(iloc))  # type: ignore
        else:
            for i, (iloc, func) in enumerate(iloc_to_func):
                row[i] = func(label, component._blocks._extract_array_column(iloc))  # type: ignore
    return row


def _reduce_component_items_worker(
    func: TUFunc,
    label_component: tp.Tuple[TLabel, TFrameOrArray],
) -> tp.Any:
    """Apply an ITEMS-style whole-component reducer ``func(label, component)`` (module
    level so a process pool can dispatch it)."""
    return func(label_component[0], label_component[1])


# -------------------------------------------------------------------------------
class Reduce:
    """The `Reduce` interface exposes methods for applying functions to one or more `Frame`s that return a new `Frame`. The `Reduce` instance is configured via constructors on `ReduceDispatch`."""

    __slots__ = ()

    _INTERFACE: tp.Tuple[str, ...] = (
        'keys',
        '__iter__',
        'items',
        'values',
        'to_frame',
    )

    def _prepare_items(
        self,
        axis: int,
        items: TIterableFrameItems,
    ) -> tp.Tuple[tp.Sequence[TLabel], tp.Sequence[TFrameOrArray], TShape2D]:
        raise NotImplementedError()  # pragma: no cover

    def _get_iter(
        self,
        components: tp.Sequence[TFrameOrArray],
        labels: tp.Sequence[TLabel],
        shape: TShape2D,
        sample: TFrameOrArray,
        is_array: bool,
    ) -> tp.Iterator[Series | TFrameAny | TNDArrayAny]:
        raise NotImplementedError()  # pragma: no cover

    # ---------------------------------------------------------------------------
    # dictionary-like interface
    _items: TIterableFrameItems
    _axis: int

    def keys(self) -> tp.Iterator[TLabel]:
        labels, _, _ = self._prepare_items(
            self._axis,
            self._items,
        )
        yield from labels

    def __iter__(self) -> tp.Iterator[TLabel]:
        yield from self.keys()

    def items(self) -> tp.Iterator[tp.Tuple[TLabel, Series | TFrameAny | TNDArrayAny]]:
        labels, components, shape = self._prepare_items(
            self._axis,
            self._items,
        )
        if components:
            sample = components[0]
        else:  # an empty iterator
            sample = EMPTY_ARRAY

        return zip(
            labels,
            self._get_iter(
                components=components,
                labels=labels,
                shape=shape,
                sample=sample,
                is_array=sample.__class__ is np.ndarray,
            ),
        )

    def values(self) -> tp.Iterator[Series | TFrameAny | TNDArrayAny]:
        yield from (v for _, v in self.items())

    def to_frame(
        self,
        *,
        index: tp.Optional[tp.Union[TIndexInitializer, TIndexAutoFactory]] = None,
        columns: tp.Optional[tp.Union[TIndexInitializer, TIndexAutoFactory]] = None,
        index_constructor: TIndexCtorSpecifier = None,
        columns_constructor: TIndexCtorSpecifier = None,
        name: TName = None,
        consolidate_blocks: bool = False,
    ) -> TFrameAny:
        """
        Return a ``Frame`` after processing column reduction functions.
        """
        raise NotImplementedError()  # pragma: no cover
        # TODO: add `retain_labels`


class ReduceComponent(Reduce):
    """`ReduceComponent` reduces by applying a function to the entire component (an array or `Frame`) and collecting the resulting `Series` or `Frame`. If an "items" iterator is used, the function will be supplied two arguments, the label and the component."""

    __slots__ = (
        '_items',
        '_func',
        '_yield_type',
        '_axis',
        '_fill_value',
        '_pool',
    )

    def __init__(
        self,
        items: TIterableFrameItems,
        func: TUFunc,
        yield_type: IterNodeType,
        axis: int = 1,
        fill_value: tp.Any = np.nan,
        pool: tp.Optional[PoolConfig] = None,
    ):
        """
        Args:
            axis_labels: Index on the axis used to label reductions.
        """
        self._items = items
        self._func = func
        self._yield_type = yield_type
        self._axis = axis
        self._fill_value = fill_value
        self._pool = pool

    def _prepare_items(
        self,
        axis: int,
        items: TIterableFrameItems,
    ) -> tp.Tuple[tp.Sequence[TLabel], tp.Sequence[TFrameOrArray], TShape2D]:
        labels: tp.List[TLabel] = []
        components: tp.List[TFrameOrArray] = []
        for label, component in items:
            labels.append(label)
            components.append(component)
        return labels, components, (-1, -1)

    def _get_iter(
        self,
        components: tp.Sequence[TFrameOrArray],
        labels: tp.Sequence[TLabel],
        shape: TShape2D,
        sample: TFrameOrArray,
        is_array: bool,
    ) -> tp.Iterator[Series | TFrameAny | TNDArrayAny]:
        """
        Return an iterator of ``Series`` after processing column reduction functions.
        """
        if self._axis != 1:  # each component reduces to a column
            raise NotImplementedError()  # pragma: no cover

        pool = self._pool
        if pool is None:
            if self._yield_type == IterNodeType.VALUES:
                for f in components:
                    yield self._func(f)
            else:
                for label, f in zip(labels, components):
                    yield self._func(label, f)
            return

        # dispatch each whole-component reduction to a thread/process pool
        executor_factory = get_concurrent_executor(
            use_threads=pool.use_threads,
            max_workers=pool.max_workers,
            mp_context=pool.mp_context,
        )
        with executor_factory() as executor:
            if self._yield_type == IterNodeType.VALUES:
                yield from executor.map(self._func, components, chunksize=pool.chunksize)
            else:
                worker = partial(_reduce_component_items_worker, self._func)
                yield from executor.map(
                    worker, zip(labels, components), chunksize=pool.chunksize
                )

    # ---------------------------------------------------------------------------
    def to_frame(
        self,
        *,
        index: tp.Optional[tp.Union[TIndexInitializer, TIndexAutoFactory]] = None,
        columns: tp.Optional[tp.Union[TIndexInitializer, TIndexAutoFactory]] = None,
        index_constructor: TIndexCtorSpecifier = None,
        columns_constructor: TIndexCtorSpecifier = None,
        name: TName = None,
        consolidate_blocks: bool = False,
    ) -> TFrameAny:
        """
        Return a ``Frame`` after processing column reduction functions.
        """

        labels, components, _ = self._prepare_items(
            self._axis,
            self._items,
        )
        if components:
            sample = components[0]
        else:
            sample = EMPTY_ARRAY

        is_array = sample.__class__ is np.ndarray
        parts = self._get_iter(
            components=components,
            labels=labels,
            shape=(-1, -1),
            sample=sample,
            is_array=is_array,
        )
        if not is_array:
            return Frame.from_concat(
                parts,  # type: ignore
                axis=0,
                union=True,
                index=index,
                index_constructor=index_constructor,
                columns=columns,
                columns_constructor=columns_constructor,
                name=name,
                consolidate_blocks=consolidate_blocks,
                fill_value=self._fill_value,
            )
        part: tp.Iterable[TNDArrayAny] = list(parts)  # type: ignore
        if not part:
            block = FRAME_INITIALIZER_DEFAULT
        else:
            block = concat_resolved(part, 0)  # immutable
        if columns is None:
            columns = IndexAutoFactory
        if index is None:
            index = IndexAutoFactory
        return Frame(
            block,  # type: ignore
            index=index,
            index_constructor=index_constructor,
            columns=columns,
            columns_constructor=columns_constructor,
            name=name,
        )


class ReduceAxis(Reduce):
    """`ReduceAxis` reduces along an axis (i.e., columns) by applying one or more functions on each column to return a 1D Series (or array) for each component."""

    __slots__ = (
        '_axis',
        '_items',
        '_axis_labels',
        '_axis_len',
        '_yield_type',
        '_pool',
    )

    _INTERFACE: tp.Tuple[str, ...] = (
        '__iter__',
        'keys',
        'values',
        'items',
        'to_frame',
    )

    _items: TIterableFrameItems
    _axis_labels: IndexBase | tp.Sequence[TLabel] | None
    _axis: int
    _axis_len: int
    _yield_type: IterNodeType
    _pool: tp.Optional[PoolConfig]

    @staticmethod
    def _derive_row_dtype_array(
        sample: TNDArrayAny,
        iloc_to_func: TListILocToFunc,
    ) -> TDtypeAny | None:
        dt_src = sample.dtype  # an array
        dtype: TDtypeAny | None = None
        for _, func in iloc_to_func:
            if not (dt := ufunc_dtype_to_dtype(func, dt_src)):
                return None
            if dtype is None:
                dtype = dt
            dtype = resolve_dtype(dtype, dt)
            if dtype == DTYPE_OBJECT:
                return dtype
        return dtype

    @staticmethod
    def _derive_row_dtype_frame(
        sample: Frame,
        iloc_to_func: TListILocToFunc,
    ) -> TDtypeAny | None:
        dt_src = sample._blocks.dtypes  # an array
        dtype = None
        for iloc, func in iloc_to_func:
            if not (dt := ufunc_dtype_to_dtype(func, dt_src[iloc])):
                return None
            if dtype is None:
                dtype = dt
            dtype = resolve_dtype(dtype, dt)
            if dtype == DTYPE_OBJECT:
                return dtype
        return dtype

    def _get_blocks(
        self,
        components: tp.Sequence[TFrameOrArray],
        labels: tp.Sequence[TLabel],
        shape: TShape2D,
        sample: TFrameOrArray,
        is_array: bool,
    ) -> tp.Sequence[TNDArrayAny]:
        raise NotImplementedError()  # pragma: no cover

    def _to_frame_fast(
        self,
        *,
        index: tp.Optional[tp.Union[TIndexInitializer, TIndexAutoFactory]],
        columns: tp.Optional[tp.Union[TIndexInitializer, TIndexAutoFactory]],
        index_constructor: TIndexCtorSpecifier,
        columns_constructor: TIndexCtorSpecifier,
        name: TName,
        consolidate_blocks: bool,
    ) -> tp.Optional[TFrameAny]:
        """A vectorized hash-group reduction, or None when it does not apply."""
        return None

    def _prepare_items(
        self,
        axis: int,
        items: TIterableFrameItems,
    ) -> tp.Tuple[tp.Sequence[TLabel], tp.Sequence[TFrameOrArray], TShape2D]:
        labels: tp.List[TLabel] = []
        components: tp.List[TFrameOrArray] = []
        for label, component in items:
            labels.append(label)
            # NOTE: could assert uniformity of shape / labels here
            components.append(component)

        shape = (len(labels), self._axis_len)  # axis == 1
        # shape = (self._axis_len, len(labels))

        return labels, components, shape

    # ---------------------------------------------------------------------------
    def to_frame(
        self,
        *,
        index: tp.Optional[tp.Union[TIndexInitializer, TIndexAutoFactory]] = None,
        columns: tp.Optional[tp.Union[TIndexInitializer, TIndexAutoFactory]] = None,
        index_constructor: TIndexCtorSpecifier = None,
        columns_constructor: TIndexCtorSpecifier = None,
        name: TName = None,
        consolidate_blocks: bool = False,
    ) -> TFrameAny:
        """
        Return a ``Frame`` after processing column reduction functions.
        """
        post = self._to_frame_fast(
            index=index,
            columns=columns,
            index_constructor=index_constructor,
            columns_constructor=columns_constructor,
            name=name,
            consolidate_blocks=consolidate_blocks,
        )
        if post is not None:
            return post

        labels, components, shape = self._prepare_items(
            self._axis,
            self._items,
        )
        if components:
            sample = components[0]
        else:  # return a zero-row Frame
            raise NotImplementedError()  # pragma: no cover

        is_array = sample.__class__ is np.ndarray
        if self._pool is not None:
            blocks = self._get_blocks_pooled(components, labels, sample, is_array)  # type: ignore
        else:
            blocks = self._get_blocks(components, labels, shape, sample, is_array)

        own_columns = False
        if columns is None:
            if isinstance(self._axis_labels, IndexBase):
                # NOTE: this is implicitly only ReduceAligned
                columns = self._axis_labels[[pair[0] for pair in self._iloc_to_func]]  # type: ignore
                own_columns = True
            else:
                columns = self._axis_labels
                own_columns = False

        # implement consolidate_blocks
        tb = TypeBlocks.from_blocks(blocks)
        if consolidate_blocks:
            tb = tb.consolidate()

        if self._axis == 1:
            if index is None:
                index = labels

        return Frame(
            tb,
            index=index,
            columns=columns,
            own_columns=own_columns,
            name=name,
            own_data=True,
            index_constructor=index_constructor,
            columns_constructor=columns_constructor,
        )


class ReduceAligned(ReduceAxis):
    """Utilities for Reducing a `Frame` (or many `Frame`) by applying functions to columns."""

    # Axis 1 will reduce components into rows (labels are the index, ilocs refer to column positions); axis 0 will reduce components into columns (labels are the column labels, ilocs refer to index positions).

    __slots__ = ('_iloc_to_func', '_group_source')

    def __init__(
        self,
        items: TIterableFrameItems,
        iloc_to_func: TListILocToFunc,
        axis_labels: IndexBase | tp.Sequence[TLabel],
        yield_type: IterNodeType,
        axis: int = 1,
        /,
        *,
        group_source: tp.Optional[tp.Tuple[TFrameAny, int, bool]] = None,
        pool: tp.Optional[PoolConfig] = None,
    ):
        """
        Args:
            axis_labels: Index on the axis used to label reductions.
            group_source: when reducing the groups of a single ``Frame`` by a single
                column key, ``(frame, key_iloc, as_array)``; enables the vectorized fast
                path. ``as_array`` marks iter_group_array (reduce at the component's
                unified 2D dtype) versus iter_group (reduce each column at its dtype).
            pool: when given, per-group reductions are dispatched to a thread or process
                pool (see ``reduce_pool``); the vectorized fast path is still preferred.
        """
        self._items = items
        self._iloc_to_func = iloc_to_func
        self._axis_labels = axis_labels
        self._yield_type = yield_type
        self._axis = axis
        self._axis_len = len(self._iloc_to_func)
        self._group_source = group_source
        self._pool = pool

    def _get_blocks_pooled(
        self,
        components: tp.Sequence[TFrameOrArray],
        labels: tp.Sequence[TLabel],
        sample: TFrameOrArray,
        is_array: bool,
    ) -> tp.Sequence[TNDArrayAny]:
        """
        Reduce each group's component to a row on a thread/process pool, then assemble
        the rows column-major (preserving per-column dtype, as ``_get_blocks`` does).
        """
        pool = self._pool
        assert pool is not None
        n_cols = self._axis_len
        worker = partial(
            _reduce_row_worker, self._iloc_to_func, is_array, self._yield_type, n_cols
        )
        executor_factory = get_concurrent_executor(
            use_threads=pool.use_threads,
            max_workers=pool.max_workers,
            mp_context=pool.mp_context,
        )
        with executor_factory() as executor:
            rows = list(
                executor.map(worker, zip(labels, components), chunksize=pool.chunksize)
            )

        size = len(rows)
        blocks: tp.List[TNDArrayAny] = []
        v: TNDArrayAny | tp.List[tp.Any]
        for j, (iloc, func) in enumerate(self._iloc_to_func):
            col_dtype = (
                sample.dtype  # type: ignore
                if is_array
                else sample._blocks.dtypes[iloc]  # type: ignore
            )
            post_dt = ufunc_dtype_to_dtype(func, col_dtype)
            col = [r[j] for r in rows]
            if post_dt is not None:
                v = np.array(col, dtype=post_dt)
            else:
                v, _ = iterable_to_array_1d(col, count=size)
            v.flags.writeable = False
            blocks.append(v)
        return blocks

    def _to_frame_fast(
        self,
        *,
        index: tp.Optional[tp.Union[TIndexInitializer, TIndexAutoFactory]],
        columns: tp.Optional[tp.Union[TIndexInitializer, TIndexAutoFactory]],
        index_constructor: TIndexCtorSpecifier,
        columns_constructor: TIndexCtorSpecifier,
        name: TName,
        consolidate_blocks: bool,
    ) -> tp.Optional[TFrameAny]:
        """
        Vectorized group reduction: factorize the single-column key once, then reduce
        each target column with ``arraykit.group_reduce`` -- an O(n) hash aggregation
        replacing the per-group Python loop. Returns None (fall back to the loop) unless
        the reduction is a single-column key on one Frame, axis-1 reduction, every
        function a recognized reducer, and every (dtype, op) supported by ``group_reduce``.

        For iter_group_array the reduction target is the component's unified 2D dtype: a
        numeric unified dtype reduces each column cast to it, while an object unified dtype
        (a mixed frame) reduces via Python numbers, so integer columns yield int64 and
        float columns yield float64 -- matching the loop's value-inferred output.

        Results are identical to the per-group loop for integer sum/prod, min/max, and
        count. Float ``sum``/``prod`` may differ from the loop by floating-point rounding
        (~1 ULP): ``group_reduce`` accumulates in array order while the loop's ``np.sum``
        accumulates in group order -- both valid, neither canonical.
        """
        source = self._group_source
        if source is None or self._axis != 1:
            return None

        # every function must map to a group_reduce op
        ops: tp.List[str] = []
        for _, func in self._iloc_to_func:
            op = _REDUCE_FUNC_TO_OP.get(func)
            if op is None:
                return None
            ops.append(op)

        frame, key_iloc, as_array = source
        blocks = frame._blocks
        key = np.ascontiguousarray(blocks._extract_array_column(key_iloc))
        if len(key) == 0:
            # zero groups: the loop path raises NotImplementedError; match it
            return None
        # the unified dtype iter_group_array would give each per-group 2D component
        unified = blocks._index.dtype if as_array else None
        try:
            # sort=True yields groups in the same (sorted, NaN-last) order as iter_group
            uniques, codes = factorize(key, sort=True)
        except (TypeError, ValueError):
            return None
        codes = np.ascontiguousarray(codes)
        size = len(uniques)

        out_blocks: tp.List[TNDArrayAny] = []
        for (iloc, func), op in zip(self._iloc_to_func, ops):
            values = blocks._extract_array_column(iloc)
            plan = _reduce_column_plan(func, op, values, unified)
            if plan is None:  # dtype the fast path cannot reproduce -> fall back
                return None
            acc_values, post_dt = plan
            try:
                block = group_reduce(codes, size, np.ascontiguousarray(acc_values), op)
            except (TypeError, ValueError):
                # dtype/op unsupported by group_reduce (e.g. float32 sum) -> fall back
                return None
            # group_reduce accumulates integers at 64-bit; numpy accumulates sum/prod at
            # the platform int, so narrow the result to that width first to reproduce the
            # loop's overflow (a no-op where the platform int is already 64-bit)
            if op in ('sum', 'prod') and block.dtype.kind in 'iu':
                accum_dt = _numpy_int_accum_dtype(acc_values.dtype)
                block = block.astype(accum_dt, copy=False)
            if block.dtype != post_dt:  # cast the 64-bit accumulator to numpy's dtype
                block = block.astype(post_dt)
            block.flags.writeable = False
            out_blocks.append(block)

        own_columns = False
        if columns is None:
            if isinstance(self._axis_labels, IndexBase):
                columns = self._axis_labels[[pair[0] for pair in self._iloc_to_func]]
                own_columns = True
            else:
                columns = self._axis_labels

        tb = TypeBlocks.from_blocks(out_blocks)
        if consolidate_blocks:
            tb = tb.consolidate()
        if index is None:
            index = uniques

        return Frame(
            tb,
            index=index,
            columns=columns,
            own_columns=own_columns,
            name=name,
            own_data=True,
            index_constructor=index_constructor,
            columns_constructor=columns_constructor,
        )

    def _get_blocks(
        self,
        components: tp.Sequence[TFrameOrArray],
        labels: tp.Sequence[TLabel],
        shape: TShape2D,
        sample: TFrameOrArray,
        is_array: bool,
    ) -> tp.Sequence[TNDArrayAny]:
        blocks: tp.List[TNDArrayAny] = []
        v: TNDArrayAny | tp.List[tp.Any]

        if is_array:
            dtype = sample.dtype  # type: ignore
            if self._axis == 1:  # each component reduces to a row
                size = shape[0]
                for iloc, func in self._iloc_to_func:
                    post_dt = ufunc_dtype_to_dtype(func, dtype)
                    if post_dt is not None:
                        v = np.empty(size, dtype=post_dt)
                    else:
                        v = [None] * size
                    if self._yield_type == IterNodeType.VALUES:
                        for i, array in enumerate(components):
                            v[i] = func(array[NULL_SLICE, iloc])
                    else:
                        for i, (label, array) in enumerate(zip(labels, components)):
                            v[i] = func(label, array[NULL_SLICE, iloc])

                    if v.__class__ is not np.ndarray:
                        v, _ = iterable_to_array_1d(v, count=size)
                    v.flags.writeable = False
                    blocks.append(v)
            else:  # each component reduces to a column
                raise NotImplementedError()  # pragma: no cover

        else:  # component is a Frame
            dtypes = sample._blocks.dtypes  # type: ignore
            if self._axis == 1:
                # each component reduces to a row
                size = shape[0]
                for iloc, func in self._iloc_to_func:
                    post_dt = ufunc_dtype_to_dtype(func, dtypes[iloc])
                    if post_dt is not None:
                        v = np.empty(size, dtype=post_dt)
                    else:
                        v = [None] * size
                    if self._yield_type == IterNodeType.VALUES:
                        for i, frame in enumerate(components):
                            v[i] = func(frame._blocks._extract_array_column(iloc))  # type: ignore
                    else:
                        for i, (label, frame) in enumerate(zip(labels, components)):
                            v[i] = func(label, frame._blocks._extract_array_column(iloc))  # type: ignore
                    if v.__class__ is not np.ndarray:
                        v, _ = iterable_to_array_1d(v, count=size)
                    v.flags.writeable = False
                    blocks.append(v)
            else:  # each component reduces to a column
                raise NotImplementedError()  # pragma: no cover
        return blocks

    def _get_iter(
        self,
        components: tp.Sequence[TFrameOrArray],
        labels: tp.Sequence[TLabel],
        shape: TShape2D,
        sample: TFrameOrArray,
        is_array: bool,
    ) -> tp.Iterator[Series | TFrameAny | TNDArrayAny]:
        """
        Return an iterator of ``Series`` after processing column reduction functions.
        """
        index: IndexBase | tp.Sequence[TLabel]

        if isinstance(self._axis_labels, IndexBase):
            # TODO: handle static
            index = self._axis_labels[[pair[0] for pair in self._iloc_to_func]]  # pyright: ignore
            own_index = True
        elif self._axis_labels is not None:
            # a sequence of labels to be used
            index = self._axis_labels
            own_index = False
        else:
            raise NotImplementedError()  # pragma: no cover

        # We are yielding rows that result from each columnar function application; using the dtype of sample, the dtype expected from func, across all funcs, we can determine the resultant array dtype and not use a list, below
        assert self._axis == 1
        v: TNDArrayAny | tp.List[tp.Any]
        size = shape[1]

        if is_array:
            if self._yield_type == IterNodeType.VALUES:
                # this only works if IterNodeType.VALUES, as we cannot identify the function of it takes a pair
                dtype = self._derive_row_dtype_array(sample, self._iloc_to_func)  # type: ignore
                if dtype is not None:
                    for array in components:
                        v = np.empty(size, dtype=dtype)
                        for i, (iloc, func) in enumerate(self._iloc_to_func):
                            v[i] = func(array[NULL_SLICE, iloc])
                        v.flags.writeable = False
                        yield v
                else:
                    for array in components:
                        v = [None] * size
                        for i, (iloc, func) in enumerate(self._iloc_to_func):
                            v[i] = func(array[NULL_SLICE, iloc])
                        v, _ = iterable_to_array_1d(v, count=size)
                        yield v
            else:  # items
                for label, array in zip(labels, components):
                    v = [None] * size
                    for i, (iloc, func) in enumerate(self._iloc_to_func):
                        v[i] = func(label, array[NULL_SLICE, iloc])
                    v, _ = iterable_to_array_1d(v, count=size)
                    yield v

        else:  # component is a Frame
            if self._yield_type == IterNodeType.VALUES:
                dtype = self._derive_row_dtype_frame(sample, self._iloc_to_func)  # type: ignore
                if dtype is not None:
                    for label, f in zip(labels, components):
                        v = np.empty(size, dtype=dtype)
                        for i, (iloc, func) in enumerate(self._iloc_to_func):
                            v[i] = func(f._extract(NULL_SLICE, iloc))  # type: ignore
                        v.flags.writeable = False
                        yield Series(v, index=index, name=label, own_index=own_index)
                else:
                    for label, f in zip(labels, components):
                        v = [None] * size
                        for i, (iloc, func) in enumerate(self._iloc_to_func):
                            v[i] = func(f._extract(NULL_SLICE, iloc))  # type: ignore
                        v, _ = iterable_to_array_1d(v, count=size)
                        yield Series(v, index=index, name=label, own_index=own_index)
            else:  # items
                for label, f in zip(labels, components):
                    v = [None] * size
                    for i, (iloc, func) in enumerate(self._iloc_to_func):
                        v[i] = func(label, f._extract(NULL_SLICE, iloc))  # type: ignore
                    v, _ = iterable_to_array_1d(v, count=size)
                    yield Series(v, index=index, name=label, own_index=own_index)


class ReduceUnaligned(ReduceAxis):
    """Utilities for Reducing a `Frame` (or many `Frame`) by applying functions to columns."""

    __slots__ = (
        '_loc_to_func',
        '_fill_value',
    )

    def __init__(
        self,
        items: TIterableFrameItems,
        loc_to_func: TListLabelToFunc,
        axis_labels: tp.Sequence[TLabel] | None,
        yield_type: IterNodeType,
        axis: int = 1,
        fill_value: tp.Any = np.nan,
        /,
    ):
        """
        Args:
            axis_labels: Index on the axis used to label reductions.
        """
        self._items = items
        self._loc_to_func = loc_to_func
        self._axis_labels = axis_labels
        self._yield_type = yield_type
        self._axis = axis
        self._axis_len = len(self._loc_to_func)
        self._fill_value = fill_value
        self._pool = None  # unaligned reduce does not support pooling

    def _get_blocks(
        self,
        components: tp.Sequence[TFrameOrArray],
        labels: tp.Sequence[TLabel],
        shape: TShape2D,
        sample: TFrameOrArray,
        is_array: bool,
    ) -> tp.Sequence[TNDArrayAny]:
        assert not is_array  # arrays cannot be supported for unaligned reduce

        blocks: tp.List[TNDArrayAny] = []
        v: TNDArrayAny | tp.List[tp.Any]
        assert self._axis == 1  # each component reduces to a row
        size = shape[0]

        for loc, func in self._loc_to_func:
            # NOTE: we cannot easily predict array type as the sample may not be representative
            v = [None] * size
            if self._yield_type == IterNodeType.VALUES:
                for i, frame in enumerate(components):
                    try:
                        iloc = frame.columns.loc_to_iloc(loc)  # type: ignore
                    except KeyError:
                        iloc = -1
                    if iloc >= 0:
                        v[i] = func(frame._blocks._extract_array_column(iloc))  # type: ignore
                    else:
                        v[i] = self._fill_value
            else:
                for i, (label, frame) in enumerate(zip(labels, components)):
                    try:
                        iloc = frame.columns.loc_to_iloc(loc)  # type: ignore
                    except KeyError:
                        iloc = -1
                    if iloc >= 0:
                        v[i] = func(label, frame._blocks._extract_array_column(iloc))  # type: ignore
                    else:
                        v[i] = self._fill_value

            v, _ = iterable_to_array_1d(v, count=size)
            v.flags.writeable = False
            blocks.append(v)

        return blocks

    def _get_iter(
        self,
        components: tp.Sequence[TFrameOrArray],
        labels: tp.Sequence[TLabel],
        shape: TShape2D,
        sample: TFrameOrArray,
        is_array: bool,
    ) -> tp.Iterator[Series | TFrameAny | TNDArrayAny]:
        """
        Return an iterator of ``Series`` after processing column reduction functions.
        """
        assert not is_array  # arrays cannot be supported for unaligned reduce
        assert self._axis == 1  # each component reduces to a row
        v: TNDArrayAny | tp.List[tp.Any]
        size = shape[1]
        fv = self._fill_value
        # NOTE: we cannot easily predict array type as we do not have a representative sample of the contained frame
        for label, f in zip(labels, components):
            v = [None] * size
            if self._yield_type == IterNodeType.VALUES:
                for i, (loc, func) in enumerate(self._loc_to_func):
                    try:
                        iloc = f.columns.loc_to_iloc(loc)  # type: ignore
                    except KeyError:
                        iloc = -1
                    if iloc >= 0:
                        v[i] = func(f._extract(NULL_SLICE, iloc))  # type: ignore
                    else:
                        v[i] = fv
            else:
                for i, (loc, func) in enumerate(self._loc_to_func):
                    try:
                        iloc = f.columns.loc_to_iloc(loc)  # type: ignore
                    except KeyError:
                        iloc = -1
                    if iloc >= 0:
                        v[i] = func(label, f._extract(NULL_SLICE, iloc))  # type: ignore
                    else:
                        v[i] = fv

            v, _ = iterable_to_array_1d(v, count=size)
            yield Series(v, index=self._axis_labels, name=label)


# -------------------------------------------------------------------------------

INTERFACE_REDUCE_DISPATCH: tp.Tuple[str, ...] = (
    'from_func',
    'from_map_func',
    'from_label_map',
    'from_label_pair_map',
)


class ReduceDispatch(Interface):
    """Interface for exposing `Reduce` constructors."""

    __slots__ = (
        '_items',
        '_yield_type',
        '_axis',
        '_pool',
    )

    CLS_DELEGATE = Reduce
    _INTERFACE = INTERFACE_REDUCE_DISPATCH

    _items: TIterableFrameItems
    _yield_type: IterNodeType
    _axis: int
    _pool: tp.Optional[PoolConfig]

[docs] def from_func( self, func: TUFunc, *, fill_value: tp.Any = np.nan, ) -> ReduceComponent: """For each `Frame`, and given a function `func` that returns either a `Series` or a `Frame`, call that function on each `Frame`.""" return ReduceComponent( self._items, func, yield_type=self._yield_type, axis=self._axis, fill_value=fill_value, pool=self._pool, )
[docs] def from_map_func( self, func: TUFunc, *, fill_value: tp.Any = np.nan, ) -> Reduce: raise NotImplementedError() # pragma: no cover
[docs] def from_label_map( self, func_map: tp.Mapping[TLabel, TUFunc], *, fill_value: tp.Any = np.nan, ) -> Reduce: raise NotImplementedError() # pragma: no cover
[docs] def from_label_pair_map( self, func_map: tp.Mapping[tp.Tuple[TLabel, TLabel], TUFunc], *, fill_value: tp.Any = np.nan, ) -> Reduce: raise NotImplementedError() # pragma: no cover
class ReduceDispatchAligned(ReduceDispatch): """Interface for creating reductions from uniform collections of Frames.""" __slots__ = ('_axis_labels', '_group_source') def __init__( self, items: TIterableFrameItems, axis_labels: IndexBase, # always an index *, yield_type: IterNodeType, axis: int = 1, group_source: tp.Optional[tp.Tuple[TFrameAny, int, bool]] = None, pool: tp.Optional[PoolConfig] = None, ) -> None: """ Args: axis_labels: Index on the axis used to label reductions. group_source: ``(frame, key_iloc, as_array)`` when these are the groups of a single Frame reduced by one column key; enables the vectorized fast path. pool: concurrency configuration (from ``reduce_pool``); dispatches per-group reductions to a thread or process pool. """ self._items = items self._axis_labels = axis_labels self._yield_type = yield_type self._axis = axis self._group_source = group_source self._pool = pool
[docs] def from_map_func( self, func: TUFunc, *, fill_value: tp.Any = np.nan, ) -> ReduceAligned: """ For each `Frame`, reduce by applying, for each column, a function that reduces to (0-dimensional) elements, where the column label and function are given as a mapping. Column labels are retained. """ iloc_to_func: TListILocToFunc = list( zip( range(len(self._axis_labels)), repeat(func), ) ) return ReduceAligned( self._items, iloc_to_func, self._axis_labels, self._yield_type, self._axis, group_source=self._group_source, pool=self._pool, )
[docs] def from_label_map( self, func_map: tp.Mapping[TLabel, TUFunc], *, fill_value: tp.Any = np.nan, ) -> ReduceAligned: """ For `Frame`, reduce by applying a function to each column, where the column label and function are given as a mapping. Column labels are retained. Args: func_map: a mapping of column labels to functions. """ loc_to_iloc = self._axis_labels.loc_to_iloc iloc_to_func: TListILocToFunc = list( (loc_to_iloc(label), func) for label, func in func_map.items() ) return ReduceAligned( self._items, iloc_to_func, self._axis_labels, self._yield_type, self._axis, group_source=self._group_source, pool=self._pool, )
[docs] def from_label_pair_map( self, func_map: tp.Mapping[tp.Tuple[TLabel, TLabel], TUFunc], *, fill_value: tp.Any = np.nan, ) -> ReduceAligned: """ For `Frame`, reduce by applying a function to a column and assigning the result a new label. Functions are provided as values in a mapping, where the key is tuple of source label, destination label. Args: func_map: a mapping of pairs of source label, destination label, to a function. """ loc_to_iloc = self._axis_labels.loc_to_iloc iloc_to_func: TListILocToFunc = [] axis_labels = [] for (iloc, label), func in func_map.items(): axis_labels.append(label) iloc_to_func.append((loc_to_iloc(iloc), func)) # NOTE: ignore self._axis_labels return ReduceAligned( self._items, iloc_to_func, axis_labels, self._yield_type, self._axis, )
# ------------------------------------------------------------------------------- class ReduceDispatchUnaligned(ReduceDispatch): """Delegate interface for creating reductions from uniform collections of Frames.""" _INTERFACE = INTERFACE_REDUCE_DISPATCH def __init__( self, items: TIterableFrameItems, *, axis: int = 1, yield_type: IterNodeType, ) -> None: """ Args: axis_labels: Index on the axis used to label reductions. """ self._items = items self._axis = axis self._yield_type = yield_type self._pool = None # unaligned reduce does not support pooling def from_map_func( self, func: TUFunc, *, fill_value: tp.Any = np.nan, ) -> ReduceComponent: # get a ReduceDispatchAligned if self._yield_type == IterNodeType.VALUES: def func_derived(f: Frame) -> Series: # pyright: ignore return next(iter(f.reduce.from_map_func(func).values())) # type: ignore else: def func_derived(l: TLabel, f: Frame) -> Series: return next(iter(f.reduce.from_map_func(func).values())) # type: ignore return ReduceComponent( self._items, func_derived, # type: ignore yield_type=self._yield_type, axis=self._axis, fill_value=fill_value, ) def from_label_map( self, func_map: tp.Mapping[TLabel, TUFunc], *, fill_value: tp.Any = np.nan, ) -> ReduceUnaligned: """ For `Frame`, reduce by applying a function to each column, where the column label and function are given as a mapping. Column labels are retained. Args: func_map: a mapping of column labels to functions. """ loc_to_func: TListLabelToFunc = [] axis_labels: tp.List[TLabel] = [] for pair in func_map.items(): axis_labels.append(pair[0]) loc_to_func.append(pair) return ReduceUnaligned( self._items, loc_to_func, axis_labels, self._yield_type, self._axis, fill_value, ) def from_label_pair_map( self, func_map: tp.Mapping[tp.Tuple[TLabel, TLabel], TUFunc], *, fill_value: tp.Any = np.nan, ) -> ReduceUnaligned: """ For `Frame`, reduce by applying a function to a column and assigning the result a new label. Functions are provided as values in a mapping, where the key is tuple of source label, destination label. Args: func_map: a mapping of pairs of source label, destination label, to a function. """ loc_to_func: TListLabelToFunc = [] axis_labels = [] for (loc, label), func in func_map.items(): axis_labels.append(label) loc_to_func.append((loc, func)) # NOTE: ignore self._axis_labels return ReduceUnaligned( self._items, loc_to_func, axis_labels, self._yield_type, self._axis, fill_value, ) # ------------------------------------------------------------------------------- class InterfaceBatchReduceDispatch(InterfaceBatch): """Alternate string interface specialized for the :obj:`Batch`.""" __slots__ = ('_batch_apply',) _INTERFACE = INTERFACE_REDUCE_DISPATCH def __init__( self, batch_apply: tp.Callable[[TCallableAny], 'Batch'], ) -> None: self._batch_apply = batch_apply # ---------------------------------------------------------------------------
[docs] def from_func( self, func: TUFunc, *, fill_value: tp.Any = np.nan, ) -> 'Batch': return self._batch_apply( lambda f: f.reduce.from_func( func, fill_value=fill_value, ).to_frame() )
[docs] def from_map_func( self, func: TUFunc, *, fill_value: tp.Any = np.nan, ) -> 'Batch': return self._batch_apply( lambda f: f.reduce.from_map_func( func, fill_value=fill_value, ).to_frame() )
[docs] def from_label_map( self, func_map: tp.Mapping[TLabel, TUFunc], *, fill_value: tp.Any = np.nan, ) -> 'Batch': return self._batch_apply( lambda f: f.reduce.from_label_map( func_map, fill_value=fill_value, ).to_frame() )
[docs] def from_label_pair_map( self, func_map: tp.Mapping[tp.Tuple[TLabel, TLabel], TUFunc], *, fill_value: tp.Any = np.nan, ) -> 'Batch': return self._batch_apply( lambda f: f.reduce.from_label_pair_map( func_map, fill_value=fill_value, ).to_frame() )