# Copyright (c) 2020-2021, NVIDIA CORPORATION.

from __future__ import annotations

import copy
import pickle
import warnings
from collections import abc
from typing import (
    Any,
    Callable,
    Dict,
    List,
    MutableMapping,
    Optional,
    Tuple,
    TypeVar,
    Union,
    cast,
)

import cupy
import numpy as np
import pandas as pd
import pyarrow as pa
from nvtx import annotate

import cudf
from cudf import _lib as libcudf
from cudf._typing import ColumnLike, DataFrameOrSeries, Dtype
from cudf.api.types import (
    _is_non_decimal_numeric_dtype,
    is_decimal_dtype,
    is_dict_like,
    is_scalar,
    issubdtype,
)
from cudf.core.column import (
    ColumnBase,
    as_column,
    build_categorical_column,
    column_empty,
    deserialize_columns,
    serialize_columns,
)
from cudf.core.column_accessor import ColumnAccessor
from cudf.core.join import Merge, MergeSemi
from cudf.core.window import Rolling
from cudf.utils import ioutils
from cudf.utils.docutils import copy_docstring
from cudf.utils.dtypes import find_common_type, is_column_like

T = TypeVar("T", bound="Frame")


class Frame:
    """A collection of Column objects with an optional index.

    Parameters
    ----------
    data : dict
        An dict mapping column names to Columns
    index : Table
        A Frame representing the (optional) index columns.
    """

    _data: "ColumnAccessor"
    # TODO: Once all dependence on Frame having an index is removed, this
    # attribute should be moved to IndexedFrame.
    _index: Optional[cudf.core.index.BaseIndex]
    _names: Optional[List]

    def __init__(self, data=None, index=None):
        if data is None:
            data = {}
        self._data = cudf.core.column_accessor.ColumnAccessor(data)
        self._index = index

    @property
    def _num_columns(self) -> int:
        return len(self._data)

    @property
    def _num_rows(self) -> int:
        if self._index is not None:
            return len(self._index)
        if len(self._data) == 0:
            return 0
        return len(self._data.columns[0])

    @property
    def _column_names(self) -> List[Any]:  # TODO: List[str]?
        return self._data.names

    @property
    def _index_names(self) -> List[Any]:  # TODO: List[str]?
        # TODO: Temporarily suppressing mypy warnings to avoid introducing bugs
        # by returning an empty list where one is not expected.
        return (
            None  # type: ignore
            if self._index is None
            else self._index._data.names
        )

    @property
    def _columns(self) -> List[Any]:  # TODO: List[Column]?
        return self._data.columns

    def serialize(self):
        header = {
            "type-serialized": pickle.dumps(type(self)),
            "column_names": pickle.dumps(tuple(self._data.names)),
        }
        header["columns"], frames = serialize_columns(self._columns)
        return header, frames

    @classmethod
    def deserialize(cls, header, frames):
        cls_deserialize = pickle.loads(header["type-serialized"])
        column_names = pickle.loads(header["column_names"])
        columns = deserialize_columns(header["columns"], frames)
        return cls_deserialize._from_data(dict(zip(column_names, columns)))

    @classmethod
    def _from_data(
        cls,
        data: MutableMapping,
        index: Optional[cudf.core.index.BaseIndex] = None,
    ):
        obj = cls.__new__(cls)
        Frame.__init__(obj, data, index)
        return obj

    @classmethod
    def _from_columns(
        cls,
        columns: List[ColumnBase],
        column_names: List[str],
        index_names: Optional[List[str]] = None,
    ):
        """Construct a `Frame` object from a list of columns.

        If `index_names` is set, the first `len(index_names)` columns are
        used to construct the index of the frame.
        """
        index = None
        n_index_columns = 0
        if index_names is not None:
            n_index_columns = len(index_names)
            index = cudf.core.index._index_from_columns(
                columns[:n_index_columns]
            )
            if isinstance(index, cudf.MultiIndex):
                index.names = index_names
            else:
                index.name = index_names[0]

        data = {
            name: columns[i + n_index_columns]
            for i, name in enumerate(column_names)
        }

        return cls._from_data(data, index)

    def _from_columns_like_self(
        self,
        columns: List[ColumnBase],
        column_names: List[str],
        index_names: Optional[List[str]] = None,
    ):
        """Construct a `Frame` from a list of columns with metadata from self.

        If `index_names` is set, the first `len(index_names)` columns are
        used to construct the index of the frame.
        """
        frame = self.__class__._from_columns(
            columns, column_names, index_names
        )
        return frame._copy_type_metadata(self, include_index=bool(index_names))

    def _mimic_inplace(
        self: T, result: Frame, inplace: bool = False
    ) -> Optional[Frame]:
        if inplace:
            for col in self._data:
                if col in result._data:
                    self._data[col]._mimic_inplace(
                        result._data[col], inplace=True
                    )
            self._data = result._data
            self._index = result._index
            return None
        else:
            return result

    @property
    def size(self):
        """
        Return the number of elements in the underlying data.

        Returns
        -------
        size : Size of the DataFrame / Index / Series / MultiIndex

        Examples
        --------
        Size of an empty dataframe is 0.

        >>> import cudf
        >>> df = cudf.DataFrame()
        >>> df
        Empty DataFrame
        Columns: []
        Index: []
        >>> df.size
        0
        >>> df = cudf.DataFrame(index=[1, 2, 3])
        >>> df
        Empty DataFrame
        Columns: []
        Index: [1, 2, 3]
        >>> df.size
        0

        DataFrame with values

        >>> df = cudf.DataFrame({'a': [10, 11, 12],
        ...         'b': ['hello', 'rapids', 'ai']})
        >>> df
            a       b
        0  10   hello
        1  11  rapids
        2  12      ai
        >>> df.size
        6
        >>> df.index
        RangeIndex(start=0, stop=3)
        >>> df.index.size
        3

        Size of an Index

        >>> index = cudf.Index([])
        >>> index
        Float64Index([], dtype='float64')
        >>> index.size
        0
        >>> index = cudf.Index([1, 2, 3, 10])
        >>> index
        Int64Index([1, 2, 3, 10], dtype='int64')
        >>> index.size
        4

        Size of a MultiIndex

        >>> midx = cudf.MultiIndex(
        ...                 levels=[["a", "b", "c", None], ["1", None, "5"]],
        ...                 codes=[[0, 0, 1, 2, 3], [0, 2, 1, 1, 0]],
        ...                 names=["x", "y"],
        ...             )
        >>> midx
        MultiIndex([( 'a',  '1'),
                    ( 'a',  '5'),
                    ( 'b', <NA>),
                    ( 'c', <NA>),
                    (<NA>,  '1')],
                   names=['x', 'y'])
        >>> midx.size
        5
        """
        return self._num_columns * self._num_rows

    @property
    def shape(self):
        """Returns a tuple representing the dimensionality of the DataFrame."""
        return self._num_rows, self._num_columns

    @property
    def empty(self):
        """
        Indicator whether DataFrame or Series is empty.

        True if DataFrame/Series is entirely empty (no items),
        meaning any of the axes are of length 0.

        Returns
        -------
        out : bool
            If DataFrame/Series is empty, return True, if not return False.

        Notes
        -----
        If DataFrame/Series contains only `null` values, it is still not
        considered empty. See the example below.

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'A' : []})
        >>> df
        Empty DataFrame
        Columns: [A]
        Index: []
        >>> df.empty
        True

        If we only have `null` values in our DataFrame, it is
        not considered empty! We will need to drop
        the `null`'s to make the DataFrame empty:

        >>> df = cudf.DataFrame({'A' : [None, None]})
        >>> df
              A
        0  <NA>
        1  <NA>
        >>> df.empty
        False
        >>> df.dropna().empty
        True

        Non-empty and empty Series example:

        >>> s = cudf.Series([1, 2, None])
        >>> s
        0       1
        1       2
        2    <NA>
        dtype: int64
        >>> s.empty
        False
        >>> s = cudf.Series([])
        >>> s
        Series([], dtype: float64)
        >>> s.empty
        True
        """
        return self.size == 0

    def __len__(self):
        return self._num_rows

    def copy(self: T, deep: bool = True) -> T:
        """
        Make a copy of this object's indices and data.

        When ``deep=True`` (default), a new object will be created with a
        copy of the calling object's data and indices. Modifications to
        the data or indices of the copy will not be reflected in the
        original object (see notes below).
        When ``deep=False``, a new object will be created without copying
        the calling object's data or index (only references to the data
        and index are copied). Any changes to the data of the original
        will be reflected in the shallow copy (and vice versa).

        Parameters
        ----------
        deep : bool, default True
            Make a deep copy, including a copy of the data and the indices.
            With ``deep=False`` neither the indices nor the data are copied.

        Returns
        -------
        copy : Series or DataFrame
            Object type matches caller.

        Examples
        --------
        >>> s = cudf.Series([1, 2], index=["a", "b"])
        >>> s
        a    1
        b    2
        dtype: int64
        >>> s_copy = s.copy()
        >>> s_copy
        a    1
        b    2
        dtype: int64

        **Shallow copy versus default (deep) copy:**

        >>> s = cudf.Series([1, 2], index=["a", "b"])
        >>> deep = s.copy()
        >>> shallow = s.copy(deep=False)

        Shallow copy shares data and index with original.

        >>> s is shallow
        False
        >>> s._column is shallow._column and s.index is shallow.index
        True

        Deep copy has own copy of data and index.

        >>> s is deep
        False
        >>> s.values is deep.values or s.index is deep.index
        False

        Updates to the data shared by shallow copy and original is reflected
        in both; deep copy remains unchanged.

        >>> s['a'] = 3
        >>> shallow['b'] = 4
        >>> s
        a    3
        b    4
        dtype: int64
        >>> shallow
        a    3
        b    4
        dtype: int64
        >>> deep
        a    1
        b    2
        dtype: int64
        """
        new_frame = self.__class__.__new__(self.__class__)
        new_frame._data = self._data.copy(deep=deep)

        if self._index is not None:
            new_frame._index = self._index.copy(deep=deep)
        else:
            new_frame._index = None

        return new_frame

    def equals(self, other, **kwargs):
        """
        Test whether two objects contain the same elements.
        This function allows two Series or DataFrames to be compared against
        each other to see if they have the same shape and elements. NaNs in
        the same location are considered equal. The column headers do not
        need to have the same type.

        Parameters
        ----------
        other : Series or DataFrame
            The other Series or DataFrame to be compared with the first.

        Returns
        -------
        bool
            True if all elements are the same in both objects, False
            otherwise.

        Examples
        --------
        >>> import cudf

        Comparing Series with `equals`:

        >>> s = cudf.Series([1, 2, 3])
        >>> other = cudf.Series([1, 2, 3])
        >>> s.equals(other)
        True
        >>> different = cudf.Series([1.5, 2, 3])
        >>> s.equals(different)
        False

        Comparing DataFrames with `equals`:

        >>> df = cudf.DataFrame({1: [10], 2: [20]})
        >>> df
            1   2
        0  10  20
        >>> exactly_equal = cudf.DataFrame({1: [10], 2: [20]})
        >>> exactly_equal
            1   2
        0  10  20
        >>> df.equals(exactly_equal)
        True

        For two DataFrames to compare equal, the types of column
        values must be equal, but the types of column labels
        need not:

        >>> different_column_type = cudf.DataFrame({1.0: [10], 2.0: [20]})
        >>> different_column_type
           1.0  2.0
        0   10   20
        >>> df.equals(different_column_type)
        True
        """
        if self is other:
            return True

        check_types = kwargs.get("check_types", True)

        if check_types:
            if type(self) is not type(other):
                return False

        if other is None or len(self) != len(other):
            return False

        # check data:
        for self_col, other_col in zip(
            self._data.values(), other._data.values()
        ):
            if not self_col.equals(other_col, check_dtypes=check_types):
                return False

        # check index:
        if self._index is None:
            return other._index is None
        else:
            return self._index.equals(other._index)

    def _explode(self, explode_column: Any, ignore_index: bool):
        """Helper function for `explode` in `Series` and `Dataframe`, explodes
        a specified nested column. Other columns' corresponding rows are
        duplicated. If ignore_index is set, the original index is not exploded
        and will be replaced with a `RangeIndex`.
        """
        explode_column_num = self._column_names.index(explode_column)
        if not ignore_index and self._index is not None:
            explode_column_num += self._index.nlevels

        res = self.__class__._from_data(  # type: ignore
            *libcudf.lists.explode_outer(
                self, explode_column_num, ignore_index
            )
        )

        res._data.multiindex = self._data.multiindex
        res._data._level_names = self._data._level_names

        if not ignore_index and self._index is not None:
            res.index.names = self._index.names
        return res

    def _get_columns_by_label(self, labels, downcast=False):
        """
        Returns columns of the Frame specified by `labels`

        """
        return self._data.select_by_label(labels)

    def _get_columns_by_index(self, indices):
        """
        Returns columns of the Frame specified by `labels`

        """
        data = self._data.select_by_index(indices)
        return self.__class__(
            data, columns=data.to_pandas_index(), index=self.index
        )

    def _as_column(self):
        """
        _as_column : Converts a single columned Frame to Column
        """
        assert (
            self._num_columns == 1
            and self._index is None
            and self._column_names[0] is None
        ), """There should be only one data column,
            no index and None as the name to use this method"""

        return self._data[None].copy(deep=False)

    def _empty_like(self, keep_index=True):
        result = self.__class__._from_data(
            *libcudf.copying.table_empty_like(self, keep_index)
        )

        result._copy_type_metadata(self, include_index=keep_index)
        return result

    @property
    def values(self):
        """
        Return a CuPy representation of the DataFrame.

        Only the values in the DataFrame will be returned, the axes labels will
        be removed.

        Returns
        -------
        cupy.ndarray
            The values of the DataFrame.
        """
        return self.to_cupy()

    @property
    def values_host(self):
        """
        Return a NumPy representation of the data.

        Only the values in the DataFrame will be returned, the axes labels will
        be removed.

        Returns
        -------
        numpy.ndarray
            A host representation of the underlying data.
        """
        return self.to_numpy()

    def __array__(self, dtype=None):
        raise TypeError(
            "Implicit conversion to a host NumPy array via __array__ is not "
            "allowed, To explicitly construct a GPU matrix, consider using "
            ".to_cupy()\nTo explicitly construct a host matrix, consider "
            "using .to_numpy()."
        )

    def __arrow_array__(self, type=None):
        raise TypeError(
            "Implicit conversion to a host PyArrow object via __arrow_array__ "
            "is not allowed. Consider using .to_arrow()"
        )

    def _to_array(
        self,
        get_column_values: Callable,
        make_empty_matrix: Callable,
        dtype: Union[Dtype, None] = None,
        na_value=None,
    ) -> Union[cupy.ndarray, np.ndarray]:
        # Internal function to implement to_cupy and to_numpy, which are nearly
        # identical except for the attribute they access to generate values.

        def get_column_values_na(col):
            if na_value is not None:
                col = col.fillna(na_value)
            return get_column_values(col)

        # Early exit for an empty Frame.
        ncol = self._num_columns
        if ncol == 0:
            return make_empty_matrix(
                shape=(0, 0), dtype=np.dtype("float64"), order="F"
            )

        if dtype is None:
            dtype = find_common_type(
                [col.dtype for col in self._data.values()]
            )

        matrix = make_empty_matrix(
            shape=(len(self), ncol), dtype=dtype, order="F"
        )
        for i, col in enumerate(self._data.values()):
            # TODO: col.values may fail if there is nullable data or an
            # unsupported dtype. We may want to catch and provide a more
            # suitable error.
            matrix[:, i] = get_column_values_na(col)
        return matrix

    def to_cupy(
        self,
        dtype: Union[Dtype, None] = None,
        copy: bool = False,
        na_value=None,
    ) -> cupy.ndarray:
        """Convert the Frame to a CuPy array.

        Parameters
        ----------
        dtype : str or numpy.dtype, optional
            The dtype to pass to :meth:`numpy.asarray`.
        copy : bool, default False
            Whether to ensure that the returned value is not a view on
            another array. Note that ``copy=False`` does not *ensure* that
            ``to_cupy()`` is no-copy. Rather, ``copy=True`` ensure that
            a copy is made, even if not strictly necessary.
        na_value : Any, default None
            The value to use for missing values. The default value depends on
            dtype and the dtypes of the DataFrame columns.

        Returns
        -------
        cupy.ndarray
        """
        return self._to_array(
            (lambda col: col.values.copy())
            if copy
            else (lambda col: col.values),
            cupy.empty,
            dtype,
            na_value,
        )

    def to_numpy(
        self,
        dtype: Union[Dtype, None] = None,
        copy: bool = True,
        na_value=None,
    ) -> np.ndarray:
        """Convert the Frame to a NumPy array.

        Parameters
        ----------
        dtype : str or numpy.dtype, optional
            The dtype to pass to :meth:`numpy.asarray`.
        copy : bool, default True
            Whether to ensure that the returned value is not a view on
            another array. This parameter must be ``True`` since cuDF must copy
            device memory to host to provide a numpy array.
        na_value : Any, default None
            The value to use for missing values. The default value depends on
            dtype and the dtypes of the DataFrame columns.

        Returns
        -------
        numpy.ndarray
        """
        if not copy:
            raise ValueError(
                "copy=False is not supported because conversion to a numpy "
                "array always copies the data."
            )

        return self._to_array(
            (lambda col: col.values_host), np.empty, dtype, na_value
        )

    def clip(self, lower=None, upper=None, inplace=False, axis=1):
        """
        Trim values at input threshold(s).

        Assigns values outside boundary to boundary values.
        Thresholds can be singular values or array like,
        and in the latter case the clipping is performed
        element-wise in the specified axis. Currently only
        `axis=1` is supported.

        Parameters
        ----------
        lower : scalar or array_like, default None
            Minimum threshold value. All values below this
            threshold will be set to it. If it is None,
            there will be no clipping based on lower.
            In case of Series/Index, lower is expected to be
            a scalar or an array of size 1.
        upper : scalar or array_like, default None
            Maximum threshold value. All values below this
            threshold will be set to it. If it is None,
            there will be no clipping based on upper.
            In case of Series, upper is expected to be
            a scalar or an array of size 1.
        inplace : bool, default False

        Returns
        -------
        Clipped DataFrame/Series/Index/MultiIndex

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({"a":[1, 2, 3, 4], "b":['a', 'b', 'c', 'd']})
        >>> df.clip(lower=[2, 'b'], upper=[3, 'c'])
           a  b
        0  2  b
        1  2  b
        2  3  c
        3  3  c

        >>> df.clip(lower=None, upper=[3, 'c'])
           a  b
        0  1  a
        1  2  b
        2  3  c
        3  3  c

        >>> df.clip(lower=[2, 'b'], upper=None)
           a  b
        0  2  b
        1  2  b
        2  3  c
        3  4  d

        >>> df.clip(lower=2, upper=3, inplace=True)
        >>> df
           a  b
        0  2  2
        1  2  3
        2  3  3
        3  3  3

        >>> import cudf
        >>> sr = cudf.Series([1, 2, 3, 4])
        >>> sr.clip(lower=2, upper=3)
        0    2
        1    2
        2    3
        3    3
        dtype: int64

        >>> sr.clip(lower=None, upper=3)
        0    1
        1    2
        2    3
        3    3
        dtype: int64

        >>> sr.clip(lower=2, upper=None, inplace=True)
        >>> sr
        0    2
        1    2
        2    3
        3    4
        dtype: int64
        """

        if axis != 1:
            raise NotImplementedError("`axis is not yet supported in clip`")

        if lower is None and upper is None:
            return None if inplace is True else self.copy(deep=True)

        if is_scalar(lower):
            lower = np.full(self._num_columns, lower)
        if is_scalar(upper):
            upper = np.full(self._num_columns, upper)

        if len(lower) != len(upper):
            raise ValueError("Length of lower and upper should be equal")

        if len(lower) != self._num_columns:
            raise ValueError(
                """Length of lower/upper should be
                equal to number of columns in
                DataFrame/Series/Index/MultiIndex"""
            )

        output = self.copy(deep=False)
        if output.ndim == 1:
            # In case of series and Index,
            # swap lower and upper if lower > upper
            if (
                lower[0] is not None
                and upper[0] is not None
                and (lower[0] > upper[0])
            ):
                lower[0], upper[0] = upper[0], lower[0]

        for i, name in enumerate(self._data):
            output._data[name] = self._data[name].clip(lower[i], upper[i])

        output._copy_type_metadata(self, include_index=False)

        return self._mimic_inplace(output, inplace=inplace)

    def where(self, cond, other=None, inplace=False):
        """
        Replace values where the condition is False.

        Parameters
        ----------
        cond : bool Series/DataFrame, array-like
            Where cond is True, keep the original value.
            Where False, replace with corresponding value from other.
            Callables are not supported.
        other: scalar, list of scalars, Series/DataFrame
            Entries where cond is False are replaced with
            corresponding value from other. Callables are not
            supported. Default is None.

            DataFrame expects only Scalar or array like with scalars or
            dataframe with same dimension as self.

            Series expects only scalar or series like with same length
        inplace : bool, default False
            Whether to perform the operation in place on the data.

        Returns
        -------
        Same type as caller

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({"A":[1, 4, 5], "B":[3, 5, 8]})
        >>> df.where(df % 2 == 0, [-1, -1])
           A  B
        0 -1 -1
        1  4 -1
        2 -1  8

        >>> ser = cudf.Series([4, 3, 2, 1, 0])
        >>> ser.where(ser > 2, 10)
        0     4
        1     3
        2    10
        3    10
        4    10
        dtype: int64
        >>> ser.where(ser > 2)
        0       4
        1       3
        2    <NA>
        3    <NA>
        4    <NA>
        dtype: int64
        """
        import cudf.core._internals.where

        return cudf.core._internals.where.where(
            frame=self, cond=cond, other=other, inplace=inplace
        )

    def mask(self, cond, other=None, inplace=False):
        """
        Replace values where the condition is True.

        Parameters
        ----------
        cond : bool Series/DataFrame, array-like
            Where cond is False, keep the original value.
            Where True, replace with corresponding value from other.
            Callables are not supported.
        other: scalar, list of scalars, Series/DataFrame
            Entries where cond is True are replaced with
            corresponding value from other. Callables are not
            supported. Default is None.

            DataFrame expects only Scalar or array like with scalars or
            dataframe with same dimension as self.

            Series expects only scalar or series like with same length
        inplace : bool, default False
            Whether to perform the operation in place on the data.

        Returns
        -------
        Same type as caller

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({"A":[1, 4, 5], "B":[3, 5, 8]})
        >>> df.mask(df % 2 == 0, [-1, -1])
           A  B
        0  1  3
        1 -1  5
        2  5 -1

        >>> ser = cudf.Series([4, 3, 2, 1, 0])
        >>> ser.mask(ser > 2, 10)
        0    10
        1    10
        2     2
        3     1
        4     0
        dtype: int64
        >>> ser.mask(ser > 2)
        0    <NA>
        1    <NA>
        2       2
        3       1
        4       0
        dtype: int64
        """

        if not hasattr(cond, "__invert__"):
            # We Invert `cond` below and call `where`, so
            # making sure the object supports
            # `~`(inversion) operator or `__invert__` method
            cond = cupy.asarray(cond)

        return self.where(cond=~cond, other=other, inplace=inplace)

    def pipe(self, func, *args, **kwargs):
        """
        Apply ``func(self, *args, **kwargs)``.

        Parameters
        ----------
        func : function
            Function to apply to the Series/DataFrame/Index.
            ``args``, and ``kwargs`` are passed into ``func``.
            Alternatively a ``(callable, data_keyword)`` tuple where
            ``data_keyword`` is a string indicating the keyword of
            ``callable`` that expects the Series/DataFrame/Index.
        args : iterable, optional
            Positional arguments passed into ``func``.
        kwargs : mapping, optional
            A dictionary of keyword arguments passed into ``func``.

        Returns
        -------
        object : the return type of ``func``.

        Examples
        --------

        Use ``.pipe`` when chaining together functions that expect
        Series, DataFrames or GroupBy objects. Instead of writing

        >>> func(g(h(df), arg1=a), arg2=b, arg3=c)

        You can write

        >>> (df.pipe(h)
        ...    .pipe(g, arg1=a)
        ...    .pipe(func, arg2=b, arg3=c)
        ... )

        If you have a function that takes the data as (say) the second
        argument, pass a tuple indicating which keyword expects the
        data. For example, suppose ``f`` takes its data as ``arg2``:

        >>> (df.pipe(h)
        ...    .pipe(g, arg1=a)
        ...    .pipe((func, 'arg2'), arg1=a, arg3=c)
        ...  )
        """
        return cudf.core.common.pipe(self, func, *args, **kwargs)

    @annotate("SCATTER_BY_MAP", color="green", domain="cudf_python")
    def scatter_by_map(
        self, map_index, map_size=None, keep_index=True, **kwargs
    ):
        """Scatter to a list of dataframes.

        Uses map_index to determine the destination
        of each row of the original DataFrame.

        Parameters
        ----------
        map_index : Series, str or list-like
            Scatter assignment for each row
        map_size : int
            Length of output list. Must be >= uniques in map_index
        keep_index : bool
            Conserve original index values for each row

        Returns
        -------
        A list of cudf.DataFrame objects.
        """

        # map_index might be a column name or array,
        # make it a Column
        if isinstance(map_index, str):
            map_index = self._data[map_index]
        elif isinstance(map_index, cudf.Series):
            map_index = map_index._column
        else:
            map_index = as_column(map_index)

        # Convert float to integer
        if map_index.dtype.kind == "f":
            map_index = map_index.astype(np.int32)

        # Convert string or categorical to integer
        if isinstance(map_index, cudf.core.column.StringColumn):
            map_index = map_index.as_categorical_column(
                "category"
            ).as_numerical
            warnings.warn(
                "Using StringColumn for map_index in scatter_by_map. "
                "Use an integer array/column for better performance."
            )
        elif isinstance(map_index, cudf.core.column.CategoricalColumn):
            map_index = map_index.as_numerical
            warnings.warn(
                "Using CategoricalColumn for map_index in scatter_by_map. "
                "Use an integer array/column for better performance."
            )

        if kwargs.get("debug", False) == 1 and map_size is not None:
            count = map_index.distinct_count()
            if map_size < count:
                raise ValueError(
                    f"ERROR: map_size must be >= {count} (got {map_size})."
                )

        data, index, output_offsets = libcudf.partitioning.partition(
            self, map_index, map_size, keep_index
        )
        partitioned = self.__class__._from_data(data, index)

        # due to the split limitation mentioned
        # here: https://github.com/rapidsai/cudf/issues/4607
        # we need to remove first & last elements in offsets.
        # TODO: Remove this after the above issue is fixed.
        output_offsets = output_offsets[1:-1]

        result = partitioned._split(output_offsets, keep_index=keep_index)

        for frame in result:
            frame._copy_type_metadata(self, include_index=keep_index)

        if map_size:
            result += [
                self._empty_like(keep_index)
                for _ in range(map_size - len(result))
            ]

        return result

    def fillna(
        self, value=None, method=None, axis=None, inplace=False, limit=None
    ):
        """Fill null values with ``value`` or specified ``method``.

        Parameters
        ----------
        value : scalar, Series-like or dict
            Value to use to fill nulls. If Series-like, null values
            are filled with values in corresponding indices.
            A dict can be used to provide different values to fill nulls
            in different columns. Cannot be used with ``method``.

        method : {'ffill', 'bfill'}, default None
            Method to use for filling null values in the dataframe or series.
            `ffill` propagates the last non-null values forward to the next
            non-null value. `bfill` propagates backward with the next non-null
            value. Cannot be used with ``value``.

        Returns
        -------
        result : DataFrame
            Copy with nulls filled.

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, None], 'b': [3, None, 5]})
        >>> df
              a     b
        0     1     3
        1     2  <NA>
        2  <NA>     5
        >>> df.fillna(4)
           a  b
        0  1  3
        1  2  4
        2  4  5
        >>> df.fillna({'a': 3, 'b': 4})
           a  b
        0  1  3
        1  2  4
        2  3  5

        ``fillna`` on a Series object:

        >>> ser = cudf.Series(['a', 'b', None, 'c'])
        >>> ser
        0       a
        1       b
        2    <NA>
        3       c
        dtype: object
        >>> ser.fillna('z')
        0    a
        1    b
        2    z
        3    c
        dtype: object

        ``fillna`` can also supports inplace operation:

        >>> ser.fillna('z', inplace=True)
        >>> ser
        0    a
        1    b
        2    z
        3    c
        dtype: object
        >>> df.fillna({'a': 3, 'b': 4}, inplace=True)
        >>> df
           a  b
        0  1  3
        1  2  4
        2  3  5

        ``fillna`` specified with fill ``method``

        >>> ser = cudf.Series([1, None, None, 2, 3, None, None])
        >>> ser.fillna(method='ffill')
        0    1
        1    1
        2    1
        3    2
        4    3
        5    3
        6    3
        dtype: int64
        >>> ser.fillna(method='bfill')
        0       1
        1       2
        2       2
        3       2
        4       3
        5    <NA>
        6    <NA>
        dtype: int64
        """
        if limit is not None:
            raise NotImplementedError("The limit keyword is not supported")
        if axis:
            raise NotImplementedError("The axis keyword is not supported")

        if value is not None and method is not None:
            raise ValueError("Cannot specify both 'value' and 'method'.")

        if method:
            if method not in {"ffill", "bfill", "pad", "backfill"}:
                raise NotImplementedError(
                    f"Fill method {method} is not supported"
                )
            if method == "pad":
                method = "ffill"
            elif method == "backfill":
                method = "bfill"

        if isinstance(value, cudf.Series):
            value = value.reindex(self._data.names)
        elif isinstance(value, cudf.DataFrame):
            if not self.index.equals(value.index):
                value = value.reindex(self.index)
            else:
                value = value
        elif not isinstance(value, abc.Mapping):
            value = {name: copy.deepcopy(value) for name in self._data.names}
        elif isinstance(value, abc.Mapping):
            value = {
                key: value.reindex(self.index)
                if isinstance(value, cudf.Series)
                else value
                for key, value in value.items()
            }

        copy_data = self._data.copy(deep=True)

        for name in copy_data.keys():
            should_fill = (
                name in value
                and not libcudf.scalar._is_null_host_scalar(value[name])
            ) or method is not None
            if should_fill:
                copy_data[name] = copy_data[name].fillna(value[name], method)
        result = self._from_data(copy_data, self._index)

        return self._mimic_inplace(result, inplace=inplace)

    def _drop_na_columns(self, how="any", subset=None, thresh=None):
        """
        Drop columns containing nulls
        """
        out_cols = []

        if subset is None:
            df = self
        else:
            df = self.take(subset)

        if thresh is None:
            if how == "all":
                thresh = 1
            else:
                thresh = len(df)

        for col in self._data.names:
            no_threshold_valid_count = (
                len(df[col]) - df[col].nans_to_nulls().null_count
            ) < thresh
            if no_threshold_valid_count:
                continue
            out_cols.append(col)

        return self[out_cols]

    def interpolate(
        self,
        method="linear",
        axis=0,
        limit=None,
        inplace=False,
        limit_direction=None,
        limit_area=None,
        downcast=None,
        **kwargs,
    ):
        """
        Interpolate data values between some points.

        Parameters
        ----------
        method : str, default 'linear'
            Interpolation technique to use. Currently,
            only 'linear` is supported.
            * 'linear': Ignore the index and treat the values as
            equally spaced. This is the only method supported on MultiIndexes.
            * 'index', 'values': linearly interpolate using the index as
            an x-axis. Unsorted indices can lead to erroneous results.
        axis : int, default 0
            Axis to interpolate along. Currently,
            only 'axis=0' is supported.
        inplace : bool, default False
            Update the data in place if possible.

        Returns
        -------
        Series or DataFrame
            Returns the same object type as the caller, interpolated at
            some or all ``NaN`` values

        """

        if method in {"pad", "ffill"} and limit_direction != "forward":
            raise ValueError(
                f"`limit_direction` must be 'forward' for method `{method}`"
            )
        if method in {"backfill", "bfill"} and limit_direction != "backward":
            raise ValueError(
                f"`limit_direction` must be 'backward' for method `{method}`"
            )

        data = self

        if not isinstance(data._index, cudf.RangeIndex):
            perm_sort = data._index.argsort()
            data = data._gather(perm_sort)

        interpolator = cudf.core.algorithms.get_column_interpolator(method)
        columns = {}
        for colname, col in data._data.items():
            if col.nullable:
                col = col.astype("float64").fillna(np.nan)

            # Interpolation methods may or may not need the index
            columns[colname] = interpolator(col, index=data._index)

        result = self._from_data(columns, index=data._index)

        return (
            result
            if isinstance(data._index, cudf.RangeIndex)
            else result._gather(perm_sort.argsort())
        )

    def _quantiles(
        self,
        q,
        interpolation="LINEAR",
        is_sorted=False,
        column_order=(),
        null_precedence=(),
    ):
        interpolation = libcudf.types.Interpolation[interpolation]

        is_sorted = libcudf.types.Sorted["YES" if is_sorted else "NO"]

        column_order = [libcudf.types.Order[key] for key in column_order]

        null_precedence = [
            libcudf.types.NullOrder[key] for key in null_precedence
        ]

        result = self.__class__._from_data(
            *libcudf.quantiles.quantiles(
                self,
                q,
                interpolation,
                is_sorted,
                column_order,
                null_precedence,
            )
        )

        result._copy_type_metadata(self)
        return result

    def rank(
        self,
        axis=0,
        method="average",
        numeric_only=None,
        na_option="keep",
        ascending=True,
        pct=False,
    ):
        """
        Compute numerical data ranks (1 through n) along axis.
        By default, equal values are assigned a rank that is the average of the
        ranks of those values.

        Parameters
        ----------
        axis : {0 or 'index'}, default 0
            Index to direct ranking.
        method : {'average', 'min', 'max', 'first', 'dense'}, default 'average'
            How to rank the group of records that have the same value
            (i.e. ties):
            * average: average rank of the group
            * min: lowest rank in the group
            * max: highest rank in the group
            * first: ranks assigned in order they appear in the array
            * dense: like 'min', but rank always increases by 1 between groups.
        numeric_only : bool, optional
            For DataFrame objects, rank only numeric columns if set to True.
        na_option : {'keep', 'top', 'bottom'}, default 'keep'
            How to rank NaN values:
            * keep: assign NaN rank to NaN values
            * top: assign smallest rank to NaN values if ascending
            * bottom: assign highest rank to NaN values if ascending.
        ascending : bool, default True
            Whether or not the elements should be ranked in ascending order.
        pct : bool, default False
            Whether or not to display the returned rankings in percentile
            form.

        Returns
        -------
        same type as caller
            Return a Series or DataFrame with data ranks as values.
        """
        if method not in {"average", "min", "max", "first", "dense"}:
            raise KeyError(method)

        method_enum = libcudf.sort.RankMethod[method.upper()]
        if na_option not in {"keep", "top", "bottom"}:
            raise ValueError(
                "na_option must be one of 'keep', 'top', or 'bottom'"
            )

        if axis not in (0, "index"):
            raise NotImplementedError(
                f"axis must be `0`/`index`, "
                f"axis={axis} is not yet supported in rank"
            )

        source = self
        if numeric_only:
            numeric_cols = (
                name
                for name in self._data.names
                if _is_non_decimal_numeric_dtype(self._data[name])
            )
            source = self._get_columns_by_label(numeric_cols)
            if source.empty:
                return source.astype("float64")

        data, index = libcudf.sort.rank_columns(
            source, method_enum, na_option, ascending, pct
        )

        return self._from_data(data, index).astype(np.float64)

    def repeat(self, repeats, axis=None):
        """Repeats elements consecutively.

        Returns a new object of caller type(DataFrame/Series/Index) where each
        element of the current object is repeated consecutively a given
        number of times.

        Parameters
        ----------
        repeats : int, or array of ints
            The number of repetitions for each element. This should
            be a non-negative integer. Repeating 0 times will return
            an empty object.

        Returns
        -------
        Series/DataFrame/Index
            A newly created object of same type as caller
            with repeated elements.

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3], 'b': [10, 20, 30]})
        >>> df
           a   b
        0  1  10
        1  2  20
        2  3  30
        >>> df.repeat(3)
           a   b
        0  1  10
        0  1  10
        0  1  10
        1  2  20
        1  2  20
        1  2  20
        2  3  30
        2  3  30
        2  3  30

        Repeat on Series

        >>> s = cudf.Series([0, 2])
        >>> s
        0    0
        1    2
        dtype: int64
        >>> s.repeat([3, 4])
        0    0
        0    0
        0    0
        1    2
        1    2
        1    2
        1    2
        dtype: int64
        >>> s.repeat(2)
        0    0
        0    0
        1    2
        1    2
        dtype: int64

        Repeat on Index

        >>> index = cudf.Index([10, 22, 33, 55])
        >>> index
        Int64Index([10, 22, 33, 55], dtype='int64')
        >>> index.repeat(5)
        Int64Index([10, 10, 10, 10, 10, 22, 22, 22, 22, 22, 33,
                    33, 33, 33, 33, 55, 55, 55, 55, 55],
                dtype='int64')
        """
        if axis is not None:
            raise NotImplementedError(
                "Only axis=`None` supported at this time."
            )

        if not is_scalar(repeats):
            repeats = as_column(repeats)

        result = self.__class__._from_data(
            *libcudf.filling.repeat(self, repeats)
        )

        result._copy_type_metadata(self)
        return result

    def shift(self, periods=1, freq=None, axis=0, fill_value=None):
        """Shift values by `periods` positions."""
        axis = self._get_axis_from_axis_arg(axis)
        if axis != 0:
            raise ValueError("Only axis=0 is supported.")
        if freq is not None:
            raise ValueError("The freq argument is not yet supported.")

        data_columns = (
            col.shift(periods, fill_value) for col in self._columns
        )
        return self.__class__._from_data(
            zip(self._column_names, data_columns), self._index
        )

    @annotate("SAMPLE", color="orange", domain="cudf_python")
    def sample(
        self,
        n=None,
        frac=None,
        replace=False,
        weights=None,
        random_state=None,
        axis=None,
        keep_index=True,
    ):
        """Return a random sample of items from an axis of object.

        You can use random_state for reproducibility.

        Parameters
        ----------
        n : int, optional
            Number of items from axis to return. Cannot be used with frac.
            Default = 1 if frac = None.
        frac : float, optional
            Fraction of axis items to return. Cannot be used with n.
        replace : bool, default False
            Allow or disallow sampling of the same row more than once.
            replace == True is not yet supported for axis = 1/"columns"
        weights : str or ndarray-like, optional
            Only supported for axis=1/"columns"
        random_state : int, numpy RandomState or None, default None
            Seed for the random number generator (if int), or None.
            If None, a random seed will be chosen.
            if RandomState, seed will be extracted from current state.
        axis : {0 or ‘index’, 1 or ‘columns’, None}, default None
            Axis to sample. Accepts axis number or name.
            Default is stat axis for given data type
            (0 for Series and DataFrames). Series and Index doesn't
            support axis=1.

        Returns
        -------
        Series or DataFrame or Index
            A new object of same type as caller containing n items
            randomly sampled from the caller object.

        Examples
        --------
        >>> import cudf as cudf
        >>> df = cudf.DataFrame({"a":{1, 2, 3, 4, 5}})
        >>> df.sample(3)
           a
        1  2
        3  4
        0  1

        >>> sr = cudf.Series([1, 2, 3, 4, 5])
        >>> sr.sample(10, replace=True)
        1    4
        3    1
        2    4
        0    5
        0    1
        4    5
        4    1
        0    2
        0    3
        3    2
        dtype: int64

        >>> df = cudf.DataFrame(
        ... {"a":[1, 2], "b":[2, 3], "c":[3, 4], "d":[4, 5]})
        >>> df.sample(2, axis=1)
           a  c
        0  1  3
        1  2  4
        """

        if frac is not None and frac > 1 and not replace:
            raise ValueError(
                "Replace has to be set to `True` "
                "when upsampling the population `frac` > 1."
            )
        elif frac is not None and n is not None:
            raise ValueError(
                "Please enter a value for `frac` OR `n`, not both"
            )

        if frac is None and n is None:
            n = 1
        elif frac is not None:
            if axis is None or axis == 0 or axis == "index":
                n = int(round(self.shape[0] * frac))
            else:
                n = int(round(self.shape[1] * frac))

        if axis is None or axis == 0 or axis == "index":
            if n > 0 and self.shape[0] == 0:
                raise ValueError(
                    "Cannot take a sample larger than 0 when axis is empty"
                )

            if not replace and n > self.shape[0]:
                raise ValueError(
                    "Cannot take a larger sample than population "
                    "when 'replace=False'"
                )

            if weights is not None:
                raise NotImplementedError(
                    "weights is not yet supported for axis=0/index"
                )

            if random_state is None:
                seed = np.random.randint(
                    np.iinfo(np.int64).max, dtype=np.int64
                )
            elif isinstance(random_state, np.random.mtrand.RandomState):
                _, keys, pos, _, _ = random_state.get_state()
                seed = 0 if pos >= len(keys) else pos
            else:
                seed = np.int64(random_state)

            result = self.__class__._from_data(
                *libcudf.copying.sample(
                    self,
                    n=n,
                    replace=replace,
                    seed=seed,
                    keep_index=keep_index,
                )
            )
            result._copy_type_metadata(self)

            return result
        else:
            if len(self.shape) != 2:
                raise ValueError(
                    f"No axis named {axis} for "
                    f"object type {self.__class__}"
                )

            if replace:
                raise NotImplementedError(
                    "Sample is not supported for "
                    f"axis {axis} when 'replace=True'"
                )

            if n > 0 and self.shape[1] == 0:
                raise ValueError(
                    "Cannot take a sample larger than 0 when axis is empty"
                )

            columns = np.asarray(self._data.names)
            if not replace and n > columns.size:
                raise ValueError(
                    "Cannot take a larger sample "
                    "than population when 'replace=False'"
                )

            if weights is not None:
                if is_column_like(weights):
                    weights = np.asarray(weights)
                else:
                    raise ValueError(
                        "Strings can only be passed to weights "
                        "when sampling from rows on a DataFrame"
                    )

                if columns.size != len(weights):
                    raise ValueError(
                        "Weights and axis to be sampled must be of same length"
                    )

                total_weight = weights.sum()
                if total_weight != 1:
                    if not isinstance(weights.dtype, float):
                        weights = weights.astype("float64")
                    weights = weights / total_weight

            np.random.seed(random_state)
            gather_map = np.random.choice(
                columns, size=n, replace=replace, p=weights
            )

            if isinstance(self, cudf.MultiIndex):
                # TODO: Need to update this once MultiIndex is refactored,
                # should be able to treat it similar to other Frame object
                result = cudf.Index(self.to_frame(index=False)[gather_map])
            else:
                result = self[gather_map]
                if not keep_index:
                    result.index = None

            return result

    @classmethod
    @annotate("FROM_ARROW", color="orange", domain="cudf_python")
    def from_arrow(cls, data):
        """Convert from PyArrow Table to Frame

        Parameters
        ----------
        data : PyArrow Table

        Raises
        ------
        TypeError for invalid input type.

        Examples
        --------
        >>> import cudf
        >>> import pyarrow as pa
        >>> data = pa.table({"a":[1, 2, 3], "b":[4, 5, 6]})
        >>> cudf.core.frame.Frame.from_arrow(data)
           a  b
        0  1  4
        1  2  5
        2  3  6
        """

        if not isinstance(data, (pa.Table)):
            raise TypeError(
                "To create a multicolumn cudf data, "
                "the data should be an arrow Table"
            )

        column_names = data.column_names
        pandas_dtypes = None
        np_dtypes = None
        if isinstance(data.schema.pandas_metadata, dict):
            metadata = data.schema.pandas_metadata
            pandas_dtypes = {
                col["field_name"]: col["pandas_type"]
                for col in metadata["columns"]
                if "field_name" in col
            }
            np_dtypes = {
                col["field_name"]: col["numpy_type"]
                for col in metadata["columns"]
                if "field_name" in col
            }

        # Currently we don't have support for
        # pyarrow.DictionaryArray -> cudf Categorical column,
        # so handling indices and dictionary as two different columns.
        # This needs be removed once we have hooked libcudf dictionary32
        # with categorical.
        dict_indices = {}
        dict_dictionaries = {}
        dict_ordered = {}
        for field in data.schema:
            if isinstance(field.type, pa.DictionaryType):
                dict_ordered[field.name] = field.type.ordered
                dict_indices[field.name] = pa.chunked_array(
                    [chunk.indices for chunk in data[field.name].chunks],
                    type=field.type.index_type,
                )
                dict_dictionaries[field.name] = pa.chunked_array(
                    [chunk.dictionary for chunk in data[field.name].chunks],
                    type=field.type.value_type,
                )

        # Handle dict arrays
        cudf_category_frame = {}
        if len(dict_indices):

            dict_indices_table = pa.table(dict_indices)
            data = data.drop(dict_indices_table.column_names)
            cudf_indices_frame, _ = libcudf.interop.from_arrow(
                dict_indices_table, dict_indices_table.column_names
            )
            # as dictionary size can vary, it can't be a single table
            cudf_dictionaries_columns = {
                name: ColumnBase.from_arrow(dict_dictionaries[name])
                for name in dict_dictionaries.keys()
            }

            for name, codes in cudf_indices_frame.items():
                cudf_category_frame[name] = build_categorical_column(
                    cudf_dictionaries_columns[name],
                    codes,
                    mask=codes.base_mask,
                    size=codes.size,
                    ordered=dict_ordered[name],
                )

        # Handle non-dict arrays
        cudf_non_category_frame = (
            {}
            if data.num_columns == 0
            else libcudf.interop.from_arrow(data, data.column_names)[0]
        )

        result = {**cudf_non_category_frame, **cudf_category_frame}

        # There are some special cases that need to be handled
        # based on metadata.
        if pandas_dtypes:
            for name in result:
                dtype = None
                if (
                    len(result[name]) == 0
                    and pandas_dtypes[name] == "categorical"
                ):
                    # When pandas_dtype is a categorical column and the size
                    # of column is 0(i.e., empty) then we will have an
                    # int8 column in result._data[name] returned by libcudf,
                    # which needs to be type-casted to 'category' dtype.
                    dtype = "category"
                elif (
                    pandas_dtypes[name] == "empty"
                    and np_dtypes[name] == "object"
                ):
                    # When a string column has all null values, pandas_dtype is
                    # is specified as 'empty' and np_dtypes as 'object',
                    # hence handling this special case to type-cast the empty
                    # float column to str column.
                    dtype = np_dtypes[name]
                elif pandas_dtypes[
                    name
                ] == "object" and cudf.api.types.is_struct_dtype(
                    np_dtypes[name]
                ):
                    # Incase of struct column, libcudf is not aware of names of
                    # struct fields, hence renaming the struct fields is
                    # necessary by extracting the field names from arrow
                    # struct types.
                    result[name] = result[name]._rename_fields(
                        [field.name for field in data[name].type]
                    )

                if dtype is not None:
                    result[name] = result[name].astype(dtype)

        return cls._from_data({name: result[name] for name in column_names})

    @annotate("TO_ARROW", color="orange", domain="cudf_python")
    def to_arrow(self):
        """
        Convert to arrow Table

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame(
        ...     {"a":[1, 2, 3], "b":[4, 5, 6]}, index=[1, 2, 3])
        >>> df.to_arrow()
        pyarrow.Table
        a: int64
        b: int64
        index: int64
        """
        return pa.Table.from_pydict(
            {name: col.to_arrow() for name, col in self._data.items()}
        )

    def _positions_from_column_names(self, column_names):
        """Map each column name into their positions in the frame.

        The order of indices returned corresponds to the column order in this
        Frame.
        """
        return [
            i
            for i, name in enumerate(self._column_names)
            if name in set(column_names)
        ]

    def replace(
        self,
        to_replace=None,
        value=None,
        inplace=False,
        limit=None,
        regex=False,
        method=None,
    ):
        """Replace values given in ``to_replace`` with ``value``.

        Parameters
        ----------
        to_replace : numeric, str or list-like
            Value(s) to replace.

            * numeric or str:
                - values equal to ``to_replace`` will be replaced
                  with ``value``
            * list of numeric or str:
                - If ``value`` is also list-like, ``to_replace`` and
                  ``value`` must be of same length.
            * dict:
                - Dicts can be used to specify different replacement values
                  for different existing values. For example, {'a': 'b',
                  'y': 'z'} replaces the value ‘a’ with ‘b’ and
                  ‘y’ with ‘z’.
                  To use a dict in this way the ``value`` parameter should
                  be ``None``.
        value : scalar, dict, list-like, str, default None
            Value to replace any values matching ``to_replace`` with.
        inplace : bool, default False
            If True, in place.

        See also
        --------
        Series.fillna

        Raises
        ------
        TypeError
            - If ``to_replace`` is not a scalar, array-like, dict, or None
            - If ``to_replace`` is a dict and value is not a list, dict,
              or Series
        ValueError
            - If a list is passed to ``to_replace`` and ``value`` but they
              are not the same length.

        Returns
        -------
        result : Series
            Series after replacement. The mask and index are preserved.

        Notes
        -----
        Parameters that are currently not supported are: `limit`, `regex`,
        `method`

        Examples
        --------
        **Series**

        Scalar ``to_replace`` and ``value``

        >>> import cudf
        >>> s = cudf.Series([0, 1, 2, 3, 4])
        >>> s
        0    0
        1    1
        2    2
        3    3
        4    4
        dtype: int64
        >>> s.replace(0, 5)
        0    5
        1    1
        2    2
        3    3
        4    4
        dtype: int64

        List-like ``to_replace``

        >>> s.replace([1, 2], 10)
        0     0
        1    10
        2    10
        3     3
        4     4
        dtype: int64

        dict-like ``to_replace``

        >>> s.replace({1:5, 3:50})
        0     0
        1     5
        2     2
        3    50
        4     4
        dtype: int64
        >>> s = cudf.Series(['b', 'a', 'a', 'b', 'a'])
        >>> s
        0     b
        1     a
        2     a
        3     b
        4     a
        dtype: object
        >>> s.replace({'a': None})
        0       b
        1    <NA>
        2    <NA>
        3       b
        4    <NA>
        dtype: object

        If there is a mimatch in types of the values in
        ``to_replace`` & ``value`` with the actual series, then
        cudf exhibits different behaviour with respect to pandas
        and the pairs are ignored silently:

        >>> s = cudf.Series(['b', 'a', 'a', 'b', 'a'])
        >>> s
        0    b
        1    a
        2    a
        3    b
        4    a
        dtype: object
        >>> s.replace('a', 1)
        0    b
        1    a
        2    a
        3    b
        4    a
        dtype: object
        >>> s.replace(['a', 'c'], [1, 2])
        0    b
        1    a
        2    a
        3    b
        4    a
        dtype: object

        **DataFrame**

        Scalar ``to_replace`` and ``value``

        >>> import cudf
        >>> df = cudf.DataFrame({'A': [0, 1, 2, 3, 4],
        ...                    'B': [5, 6, 7, 8, 9],
        ...                    'C': ['a', 'b', 'c', 'd', 'e']})
        >>> df
           A  B  C
        0  0  5  a
        1  1  6  b
        2  2  7  c
        3  3  8  d
        4  4  9  e
        >>> df.replace(0, 5)
           A  B  C
        0  5  5  a
        1  1  6  b
        2  2  7  c
        3  3  8  d
        4  4  9  e

        List-like ``to_replace``

        >>> df.replace([0, 1, 2, 3], 4)
           A  B  C
        0  4  5  a
        1  4  6  b
        2  4  7  c
        3  4  8  d
        4  4  9  e
        >>> df.replace([0, 1, 2, 3], [4, 3, 2, 1])
           A  B  C
        0  4  5  a
        1  3  6  b
        2  2  7  c
        3  1  8  d
        4  4  9  e

        dict-like ``to_replace``

        >>> df.replace({0: 10, 1: 100})
             A  B  C
        0   10  5  a
        1  100  6  b
        2    2  7  c
        3    3  8  d
        4    4  9  e
        >>> df.replace({'A': 0, 'B': 5}, 100)
             A    B  C
        0  100  100  a
        1    1    6  b
        2    2    7  c
        3    3    8  d
        4    4    9  e
        """
        if limit is not None:
            raise NotImplementedError("limit parameter is not implemented yet")

        if regex:
            raise NotImplementedError("regex parameter is not implemented yet")

        if method not in ("pad", None):
            raise NotImplementedError(
                "method parameter is not implemented yet"
            )

        if not (to_replace is None and value is None):
            copy_data = self._data.copy(deep=False)
            (
                all_na_per_column,
                to_replace_per_column,
                replacements_per_column,
            ) = _get_replacement_values_for_columns(
                to_replace=to_replace,
                value=value,
                columns_dtype_map={
                    col: copy_data._data[col].dtype for col in copy_data._data
                },
            )

            for name, col in copy_data.items():
                try:
                    copy_data[name] = col.find_and_replace(
                        to_replace_per_column[name],
                        replacements_per_column[name],
                        all_na_per_column[name],
                    )
                except (KeyError, OverflowError):
                    # We need to create a deep copy if:
                    # i. `find_and_replace` was not successful or any of
                    #    `to_replace_per_column`, `replacements_per_column`,
                    #    `all_na_per_column` don't contain the `name`
                    #    that exists in `copy_data`.
                    # ii. There is an OverflowError while trying to cast
                    #     `to_replace_per_column` to `replacements_per_column`.
                    copy_data[name] = col.copy(deep=True)
        else:
            copy_data = self._data.copy(deep=True)

        result = self._from_data(copy_data, self._index)

        return self._mimic_inplace(result, inplace=inplace)

    def _copy_type_metadata(
        self, other: Frame, include_index: bool = True
    ) -> Frame:
        """
        Copy type metadata from each column of `other` to the corresponding
        column of `self`.
        See `ColumnBase._with_type_metadata` for more information.
        """
        for name, col, other_col in zip(
            self._data.keys(), self._data.values(), other._data.values()
        ):
            self._data.set_by_label(
                name, col._with_type_metadata(other_col.dtype), validate=False
            )

        if include_index:
            if self._index is not None and other._index is not None:
                self._index._copy_type_metadata(other._index)  # type: ignore
                # When other._index is a CategoricalIndex, the current index
                # will be a NumericalIndex with an underlying CategoricalColumn
                # (the above _copy_type_metadata call will have converted the
                # column). Calling cudf.Index on that column generates the
                # appropriate index.
                if isinstance(
                    other._index, cudf.core.index.CategoricalIndex
                ) and not isinstance(
                    self._index, cudf.core.index.CategoricalIndex
                ):
                    self._index = cudf.Index(
                        cast(
                            cudf.core.index.NumericIndex, self._index
                        )._column,
                        name=self._index.name,
                    )

        return self

    def isnull(self):
        """
        Identify missing values.

        Return a boolean same-sized object indicating if
        the values are ``<NA>``. ``<NA>`` values gets mapped to
        ``True`` values. Everything else gets mapped to
        ``False`` values. ``<NA>`` values include:

        * Values where null mask is set.
        * ``NaN`` in float dtype.
        * ``NaT`` in datetime64 and timedelta64 types.

        Characters such as empty strings ``''`` or
        ``inf`` incase of float are not
        considered ``<NA>`` values.

        Returns
        -------
        DataFrame/Series/Index
            Mask of bool values for each element in
            the object that indicates whether an element is an NA value.

        Examples
        --------

        Show which entries in a DataFrame are NA.

        >>> import cudf
        >>> import numpy as np
        >>> import pandas as pd
        >>> df = cudf.DataFrame({'age': [5, 6, np.NaN],
        ...                    'born': [pd.NaT, pd.Timestamp('1939-05-27'),
        ...                             pd.Timestamp('1940-04-25')],
        ...                    'name': ['Alfred', 'Batman', ''],
        ...                    'toy': [None, 'Batmobile', 'Joker']})
        >>> df
            age                        born    name        toy
        0     5                        <NA>  Alfred       <NA>
        1     6  1939-05-27 00:00:00.000000  Batman  Batmobile
        2  <NA>  1940-04-25 00:00:00.000000              Joker
        >>> df.isnull()
             age   born   name    toy
        0  False   True  False   True
        1  False  False  False  False
        2   True  False  False  False

        Show which entries in a Series are NA.

        >>> ser = cudf.Series([5, 6, np.NaN, np.inf, -np.inf])
        >>> ser
        0     5.0
        1     6.0
        2    <NA>
        3     Inf
        4    -Inf
        dtype: float64
        >>> ser.isnull()
        0    False
        1    False
        2     True
        3    False
        4    False
        dtype: bool

        Show which entries in an Index are NA.

        >>> idx = cudf.Index([1, 2, None, np.NaN, 0.32, np.inf])
        >>> idx
        Float64Index([1.0, 2.0, <NA>, <NA>, 0.32, Inf], dtype='float64')
        >>> idx.isnull()
        GenericIndex([False, False, True, True, False, False], dtype='bool')
        """
        data_columns = (col.isnull() for col in self._columns)
        return self.__class__._from_data(
            zip(self._column_names, data_columns), self._index
        )

    # Alias for isnull
    isna = isnull

    def notnull(self):
        """
        Identify non-missing values.

        Return a boolean same-sized object indicating if
        the values are not ``<NA>``. Non-missing values get
        mapped to ``True``. ``<NA>`` values get mapped to
        ``False`` values. ``<NA>`` values include:

        * Values where null mask is set.
        * ``NaN`` in float dtype.
        * ``NaT`` in datetime64 and timedelta64 types.

        Characters such as empty strings ``''`` or
        ``inf`` incase of float are not
        considered ``<NA>`` values.

        Returns
        -------
        DataFrame/Series/Index
            Mask of bool values for each element in
            the object that indicates whether an element is not an NA value.

        Examples
        --------

        Show which entries in a DataFrame are NA.

        >>> import cudf
        >>> import numpy as np
        >>> import pandas as pd
        >>> df = cudf.DataFrame({'age': [5, 6, np.NaN],
        ...                    'born': [pd.NaT, pd.Timestamp('1939-05-27'),
        ...                             pd.Timestamp('1940-04-25')],
        ...                    'name': ['Alfred', 'Batman', ''],
        ...                    'toy': [None, 'Batmobile', 'Joker']})
        >>> df
            age                        born    name        toy
        0     5                        <NA>  Alfred       <NA>
        1     6  1939-05-27 00:00:00.000000  Batman  Batmobile
        2  <NA>  1940-04-25 00:00:00.000000              Joker
        >>> df.notnull()
             age   born  name    toy
        0   True  False  True  False
        1   True   True  True   True
        2  False   True  True   True

        Show which entries in a Series are NA.

        >>> ser = cudf.Series([5, 6, np.NaN, np.inf, -np.inf])
        >>> ser
        0     5.0
        1     6.0
        2    <NA>
        3     Inf
        4    -Inf
        dtype: float64
        >>> ser.notnull()
        0     True
        1     True
        2    False
        3     True
        4     True
        dtype: bool

        Show which entries in an Index are NA.

        >>> idx = cudf.Index([1, 2, None, np.NaN, 0.32, np.inf])
        >>> idx
        Float64Index([1.0, 2.0, <NA>, <NA>, 0.32, Inf], dtype='float64')
        >>> idx.notnull()
        GenericIndex([True, True, False, False, True, True], dtype='bool')
        """
        data_columns = (col.notnull() for col in self._columns)
        return self.__class__._from_data(
            zip(self._column_names, data_columns), self._index
        )

    # Alias for notnull
    notna = notnull

    def interleave_columns(self):
        """
        Interleave Series columns of a table into a single column.

        Converts the column major table `cols` into a row major column.

        Parameters
        ----------
        cols : input Table containing columns to interleave.

        Examples
        --------
        >>> df = DataFrame([['A1', 'A2', 'A3'], ['B1', 'B2', 'B3']])
        >>> df
        0    [A1, A2, A3]
        1    [B1, B2, B3]
        >>> df.interleave_columns()
        0    A1
        1    B1
        2    A2
        3    B2
        4    A3
        5    B3

        Returns
        -------
        The interleaved columns as a single column
        """
        if ("category" == self.dtypes).any():
            raise ValueError(
                "interleave_columns does not support 'category' dtype."
            )

        result = self._constructor_sliced(
            libcudf.reshape.interleave_columns(self)
        )

        return result

    def tile(self, count):
        """
        Repeats the rows from `self` DataFrame `count` times to form a
        new DataFrame.

        Parameters
        ----------
        self : input Table containing columns to interleave.
        count : Number of times to tile "rows". Must be non-negative.

        Examples
        --------
        >>> df  = Dataframe([[8, 4, 7], [5, 2, 3]])
        >>> count = 2
        >>> df.tile(df, count)
           0  1  2
        0  8  4  7
        1  5  2  3
        0  8  4  7
        1  5  2  3

        Returns
        -------
        The table containing the tiled "rows".
        """
        result = self.__class__._from_data(*libcudf.reshape.tile(self, count))
        result._copy_type_metadata(self)
        return result

    def searchsorted(
        self, values, side="left", ascending=True, na_position="last"
    ):
        """Find indices where elements should be inserted to maintain order

        Parameters
        ----------
        value : Frame (Shape must be consistent with self)
            Values to be hypothetically inserted into Self
        side : str {‘left’, ‘right’} optional, default ‘left‘
            If ‘left’, the index of the first suitable location found is given
            If ‘right’, return the last such index
        ascending : bool optional, default True
            Sorted Frame is in ascending order (otherwise descending)
        na_position : str {‘last’, ‘first’} optional, default ‘last‘
            Position of null values in sorted order

        Returns
        -------
        1-D cupy array of insertion points

        Examples
        --------
        >>> s = cudf.Series([1, 2, 3])
        >>> s.searchsorted(4)
        3
        >>> s.searchsorted([0, 4])
        array([0, 3], dtype=int32)
        >>> s.searchsorted([1, 3], side='left')
        array([0, 2], dtype=int32)
        >>> s.searchsorted([1, 3], side='right')
        array([1, 3], dtype=int32)

        If the values are not monotonically sorted, wrong
        locations may be returned:

        >>> s = cudf.Series([2, 1, 3])
        >>> s.searchsorted(1)
        0   # wrong result, correct would be 1

        >>> df = cudf.DataFrame({'a': [1, 3, 5, 7], 'b': [10, 12, 14, 16]})
        >>> df
           a   b
        0  1  10
        1  3  12
        2  5  14
        3  7  16
        >>> values_df = cudf.DataFrame({'a': [0, 2, 5, 6],
        ... 'b': [10, 11, 13, 15]})
        >>> values_df
           a   b
        0  0  10
        1  2  17
        2  5  13
        3  6  15
        >>> df.searchsorted(values_df, ascending=False)
        array([4, 4, 4, 0], dtype=int32)
        """
        # Call libcudf++ search_sorted primitive

        if na_position not in {"first", "last"}:
            raise ValueError(f"invalid na_position: {na_position}")

        scalar_flag = None
        if is_scalar(values):
            scalar_flag = True

        if not isinstance(values, Frame):
            values = as_column(values)
            if values.dtype != self.dtype:
                self = self.astype(values.dtype)
            values = values.as_frame()
        outcol = libcudf.search.search_sorted(
            self, values, side, ascending=ascending, na_position=na_position
        )

        # Retrun result as cupy array if the values is non-scalar
        # If values is scalar, result is expected to be scalar.
        result = cupy.asarray(outcol.data_array_view)
        if scalar_flag:
            return result[0].item()
        else:
            return result

    @annotate("ARGSORT", color="yellow", domain="cudf_python")
    def argsort(
        self,
        by=None,
        axis=0,
        kind="quicksort",
        order=None,
        ascending=True,
        na_position="last",
    ):
        """Return the integer indices that would sort the Series values.

        Parameters
        ----------
        by : str or list of str, default None
            Name or list of names to sort by. If None, sort by all columns.
        axis : {0 or "index"}
            Has no effect but is accepted for compatibility with numpy.
        kind : {'mergesort', 'quicksort', 'heapsort', 'stable'}, default 'quicksort'
            Choice of sorting algorithm. See :func:`numpy.sort` for more
            information. 'mergesort' and 'stable' are the only stable
            algorithms. Only quicksort is supported in cuDF.
        order : None
            Has no effect but is accepted for compatibility with numpy.
        ascending : bool or list of bool, default True
            If True, sort values in ascending order, otherwise descending.
        na_position : {‘first’ or ‘last’}, default ‘last’
            Argument ‘first’ puts NaNs at the beginning, ‘last’ puts NaNs
            at the end.

        Returns
        -------
        cupy.ndarray: The indices sorted based on input.

        Examples
        --------
        **Series**

        >>> import cudf
        >>> s = cudf.Series([3, 1, 2])
        >>> s
        0    3
        1    1
        2    2
        dtype: int64
        >>> s.argsort()
        0    1
        1    2
        2    0
        dtype: int32
        >>> s[s.argsort()]
        1    1
        2    2
        0    3
        dtype: int64

        **DataFrame**
        >>> import cudf
        >>> df = cudf.DataFrame({'foo': [3, 1, 2]})
        >>> df.argsort()
        array([1, 2, 0], dtype=int32)

        **Index**
        >>> import cudf
        >>> idx = cudf.Index([3, 1, 2])
        >>> idx.argsort()
        array([1, 2, 0], dtype=int32)
        """  # noqa: E501
        if na_position not in {"first", "last"}:
            raise ValueError(f"invalid na_position: {na_position}")
        if kind != "quicksort":
            if kind not in {"mergesort", "heapsort", "stable"}:
                raise AttributeError(
                    f"{kind} is not a valid sorting algorithm for "
                    f"'DataFrame' object"
                )
            warnings.warn(
                f"GPU-accelerated {kind} is currently not supported, "
                "defaulting to quicksort."
            )

        if isinstance(by, str):
            by = [by]
        return self._get_sorted_inds(
            by=by, ascending=ascending, na_position=na_position
        ).values

    def _get_sorted_inds(self, by=None, ascending=True, na_position="last"):
        # Get an int64 column consisting of the indices required to sort self
        # according to the columns specified in by.

        to_sort = (
            self
            if by is None
            else self._get_columns_by_label(list(by), downcast=False)
        )

        # If given a scalar need to construct a sequence of length # of columns
        if np.isscalar(ascending):
            ascending = [ascending] * to_sort._num_columns

        return libcudf.sort.order_by(to_sort, ascending, na_position)

    def sin(self):
        """
        Get Trigonometric sine, element-wise.

        Returns
        -------
        DataFrame/Series/Index
            Result of the trigonometric operation.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([0.0, 0.32434, 0.5, 45, 90, 180, 360])
        >>> ser
        0      0.00000
        1      0.32434
        2      0.50000
        3     45.00000
        4     90.00000
        5    180.00000
        6    360.00000
        dtype: float64
        >>> ser.sin()
        0    0.000000
        1    0.318683
        2    0.479426
        3    0.850904
        4    0.893997
        5   -0.801153
        6    0.958916
        dtype: float64

        `sin` operation on DataFrame:

        >>> df = cudf.DataFrame({'first': [0.0, 5, 10, 15],
        ...                      'second': [100.0, 360, 720, 300]})
        >>> df
           first  second
        0    0.0   100.0
        1    5.0   360.0
        2   10.0   720.0
        3   15.0   300.0
        >>> df.sin()
              first    second
        0  0.000000 -0.506366
        1 -0.958924  0.958916
        2 -0.544021 -0.544072
        3  0.650288 -0.999756

        `sin` operation on Index:

        >>> index = cudf.Index([-0.4, 100, -180, 90])
        >>> index
        Float64Index([-0.4, 100.0, -180.0, 90.0], dtype='float64')
        >>> index.sin()
        Float64Index([-0.3894183423086505, -0.5063656411097588,
                    0.8011526357338306, 0.8939966636005579],
                    dtype='float64')
        """
        return self._unaryop("sin")

    def cos(self):
        """
        Get Trigonometric cosine, element-wise.

        Returns
        -------
        DataFrame/Series/Index
            Result of the trigonometric operation.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([0.0, 0.32434, 0.5, 45, 90, 180, 360])
        >>> ser
        0      0.00000
        1      0.32434
        2      0.50000
        3     45.00000
        4     90.00000
        5    180.00000
        6    360.00000
        dtype: float64
        >>> ser.cos()
        0    1.000000
        1    0.947861
        2    0.877583
        3    0.525322
        4   -0.448074
        5   -0.598460
        6   -0.283691
        dtype: float64

        `cos` operation on DataFrame:

        >>> df = cudf.DataFrame({'first': [0.0, 5, 10, 15],
        ...                      'second': [100.0, 360, 720, 300]})
        >>> df
           first  second
        0    0.0   100.0
        1    5.0   360.0
        2   10.0   720.0
        3   15.0   300.0
        >>> df.cos()
              first    second
        0  1.000000  0.862319
        1  0.283662 -0.283691
        2 -0.839072 -0.839039
        3 -0.759688 -0.022097

        `cos` operation on Index:

        >>> index = cudf.Index([-0.4, 100, -180, 90])
        >>> index
        Float64Index([-0.4, 100.0, -180.0, 90.0], dtype='float64')
        >>> index.cos()
        Float64Index([ 0.9210609940028851,  0.8623188722876839,
                    -0.5984600690578581, -0.4480736161291701],
                    dtype='float64')
        """
        return self._unaryop("cos")

    def tan(self):
        """
        Get Trigonometric tangent, element-wise.

        Returns
        -------
        DataFrame/Series/Index
            Result of the trigonometric operation.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([0.0, 0.32434, 0.5, 45, 90, 180, 360])
        >>> ser
        0      0.00000
        1      0.32434
        2      0.50000
        3     45.00000
        4     90.00000
        5    180.00000
        6    360.00000
        dtype: float64
        >>> ser.tan()
        0    0.000000
        1    0.336213
        2    0.546302
        3    1.619775
        4   -1.995200
        5    1.338690
        6   -3.380140
        dtype: float64

        `tan` operation on DataFrame:

        >>> df = cudf.DataFrame({'first': [0.0, 5, 10, 15],
        ...                      'second': [100.0, 360, 720, 300]})
        >>> df
           first  second
        0    0.0   100.0
        1    5.0   360.0
        2   10.0   720.0
        3   15.0   300.0
        >>> df.tan()
              first     second
        0  0.000000  -0.587214
        1 -3.380515  -3.380140
        2  0.648361   0.648446
        3 -0.855993  45.244742

        `tan` operation on Index:

        >>> index = cudf.Index([-0.4, 100, -180, 90])
        >>> index
        Float64Index([-0.4, 100.0, -180.0, 90.0], dtype='float64')
        >>> index.tan()
        Float64Index([-0.4227932187381618,  -0.587213915156929,
                    -1.3386902103511544, -1.995200412208242],
                    dtype='float64')
        """
        return self._unaryop("tan")

    def asin(self):
        """
        Get Trigonometric inverse sine, element-wise.

        The inverse of sine so that, if y = x.sin(), then x = y.asin()

        Returns
        -------
        DataFrame/Series/Index
            Result of the trigonometric operation.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([-1, 0, 1, 0.32434, 0.5])
        >>> ser.asin()
        0   -1.570796
        1    0.000000
        2    1.570796
        3    0.330314
        4    0.523599
        dtype: float64

        `asin` operation on DataFrame:

        >>> df = cudf.DataFrame({'first': [-1, 0, 0.5],
        ...                      'second': [0.234, 0.3, 0.1]})
        >>> df
           first  second
        0   -1.0   0.234
        1    0.0   0.300
        2    0.5   0.100
        >>> df.asin()
              first    second
        0 -1.570796  0.236190
        1  0.000000  0.304693
        2  0.523599  0.100167

        `asin` operation on Index:

        >>> index = cudf.Index([-1, 0.4, 1, 0.3])
        >>> index
        Float64Index([-1.0, 0.4, 1.0, 0.3], dtype='float64')
        >>> index.asin()
        Float64Index([-1.5707963267948966, 0.41151684606748806,
                    1.5707963267948966, 0.3046926540153975],
                    dtype='float64')
        """
        return self._unaryop("asin")

    def acos(self):
        """
        Get Trigonometric inverse cosine, element-wise.

        The inverse of cos so that, if y = x.cos(), then x = y.acos()

        Returns
        -------
        DataFrame/Series/Index
            Result of the trigonometric operation.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([-1, 0, 1, 0.32434, 0.5])
        >>> ser.acos()
        0    3.141593
        1    1.570796
        2    0.000000
        3    1.240482
        4    1.047198
        dtype: float64

        `acos` operation on DataFrame:

        >>> df = cudf.DataFrame({'first': [-1, 0, 0.5],
        ...                      'second': [0.234, 0.3, 0.1]})
        >>> df
           first  second
        0   -1.0   0.234
        1    0.0   0.300
        2    0.5   0.100
        >>> df.acos()
              first    second
        0  3.141593  1.334606
        1  1.570796  1.266104
        2  1.047198  1.470629

        `acos` operation on Index:

        >>> index = cudf.Index([-1, 0.4, 1, 0, 0.3])
        >>> index
        Float64Index([-1.0, 0.4, 1.0, 0.0, 0.3], dtype='float64')
        >>> index.acos()
        Float64Index([ 3.141592653589793, 1.1592794807274085, 0.0,
                    1.5707963267948966,  1.266103672779499],
                    dtype='float64')
        """
        result = self.copy(deep=False)
        for col in result._data:
            min_float_dtype = cudf.utils.dtypes.get_min_float_dtype(
                result._data[col]
            )
            result._data[col] = result._data[col].astype(min_float_dtype)
        result = result._unaryop("acos")
        result = result.mask((result < 0) | (result > np.pi + 1))
        return result

    def atan(self):
        """
        Get Trigonometric inverse tangent, element-wise.

        The inverse of tan so that, if y = x.tan(), then x = y.atan()

        Returns
        -------
        DataFrame/Series/Index
            Result of the trigonometric operation.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([-1, 0, 1, 0.32434, 0.5, -10])
        >>> ser
        0    -1.00000
        1     0.00000
        2     1.00000
        3     0.32434
        4     0.50000
        5   -10.00000
        dtype: float64
        >>> ser.atan()
        0   -0.785398
        1    0.000000
        2    0.785398
        3    0.313635
        4    0.463648
        5   -1.471128
        dtype: float64

        `atan` operation on DataFrame:

        >>> df = cudf.DataFrame({'first': [-1, -10, 0.5],
        ...                      'second': [0.234, 0.3, 10]})
        >>> df
           first  second
        0   -1.0   0.234
        1  -10.0   0.300
        2    0.5  10.000
        >>> df.atan()
              first    second
        0 -0.785398  0.229864
        1 -1.471128  0.291457
        2  0.463648  1.471128

        `atan` operation on Index:

        >>> index = cudf.Index([-1, 0.4, 1, 0, 0.3])
        >>> index
        Float64Index([-1.0, 0.4, 1.0, 0.0, 0.3], dtype='float64')
        >>> index.atan()
        Float64Index([-0.7853981633974483,  0.3805063771123649,
                                    0.7853981633974483, 0.0,
                                    0.2914567944778671],
                    dtype='float64')
        """
        return self._unaryop("atan")

    def exp(self):
        """
        Get the exponential of all elements, element-wise.

        Exponential is the inverse of the log function,
        so that x.exp().log() = x

        Returns
        -------
        DataFrame/Series/Index
            Result of the element-wise exponential.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([-1, 0, 1, 0.32434, 0.5, -10, 100])
        >>> ser
        0     -1.00000
        1      0.00000
        2      1.00000
        3      0.32434
        4      0.50000
        5    -10.00000
        6    100.00000
        dtype: float64
        >>> ser.exp()
        0    3.678794e-01
        1    1.000000e+00
        2    2.718282e+00
        3    1.383117e+00
        4    1.648721e+00
        5    4.539993e-05
        6    2.688117e+43
        dtype: float64

        `exp` operation on DataFrame:

        >>> df = cudf.DataFrame({'first': [-1, -10, 0.5],
        ...                      'second': [0.234, 0.3, 10]})
        >>> df
           first  second
        0   -1.0   0.234
        1  -10.0   0.300
        2    0.5  10.000
        >>> df.exp()
              first        second
        0  0.367879      1.263644
        1  0.000045      1.349859
        2  1.648721  22026.465795

        `exp` operation on Index:

        >>> index = cudf.Index([-1, 0.4, 1, 0, 0.3])
        >>> index
        Float64Index([-1.0, 0.4, 1.0, 0.0, 0.3], dtype='float64')
        >>> index.exp()
        Float64Index([0.36787944117144233,  1.4918246976412703,
                      2.718281828459045, 1.0,  1.3498588075760032],
                    dtype='float64')
        """
        return self._unaryop("exp")

    def log(self):
        """
        Get the natural logarithm of all elements, element-wise.

        Natural logarithm is the inverse of the exp function,
        so that x.log().exp() = x

        Returns
        -------
        DataFrame/Series/Index
            Result of the element-wise natural logarithm.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([-1, 0, 1, 0.32434, 0.5, -10, 100])
        >>> ser
        0     -1.00000
        1      0.00000
        2      1.00000
        3      0.32434
        4      0.50000
        5    -10.00000
        6    100.00000
        dtype: float64
        >>> ser.log()
        0         NaN
        1        -inf
        2    0.000000
        3   -1.125963
        4   -0.693147
        5         NaN
        6    4.605170
        dtype: float64

        `log` operation on DataFrame:

        >>> df = cudf.DataFrame({'first': [-1, -10, 0.5],
        ...                      'second': [0.234, 0.3, 10]})
        >>> df
           first  second
        0   -1.0   0.234
        1  -10.0   0.300
        2    0.5  10.000
        >>> df.log()
              first    second
        0       NaN -1.452434
        1       NaN -1.203973
        2 -0.693147  2.302585

        `log` operation on Index:

        >>> index = cudf.Index([10, 11, 500.0])
        >>> index
        Float64Index([10.0, 11.0, 500.0], dtype='float64')
        >>> index.log()
        Float64Index([2.302585092994046, 2.3978952727983707,
                    6.214608098422191], dtype='float64')
        """
        return self._unaryop("log")

    def sqrt(self):
        """
        Get the non-negative square-root of all elements, element-wise.

        Returns
        -------
        DataFrame/Series/Index
            Result of the non-negative
            square-root of each element.

        Examples
        --------
        >>> import cudf
        >>> import cudf
        >>> ser = cudf.Series([10, 25, 81, 1.0, 100])
        >>> ser
        0     10.0
        1     25.0
        2     81.0
        3      1.0
        4    100.0
        dtype: float64
        >>> ser.sqrt()
        0     3.162278
        1     5.000000
        2     9.000000
        3     1.000000
        4    10.000000
        dtype: float64

        `sqrt` operation on DataFrame:

        >>> df = cudf.DataFrame({'first': [-10.0, 100, 625],
        ...                      'second': [1, 2, 0.4]})
        >>> df
           first  second
        0  -10.0     1.0
        1  100.0     2.0
        2  625.0     0.4
        >>> df.sqrt()
           first    second
        0    NaN  1.000000
        1   10.0  1.414214
        2   25.0  0.632456

        `sqrt` operation on Index:

        >>> index = cudf.Index([-10.0, 100, 625])
        >>> index
        Float64Index([-10.0, 100.0, 625.0], dtype='float64')
        >>> index.sqrt()
        Float64Index([nan, 10.0, 25.0], dtype='float64')
        """
        return self._unaryop("sqrt")

    def abs(self):
        """
        Return a Series/DataFrame with absolute numeric value of each element.

        This function only applies to elements that are all numeric.

        Returns
        -------
        DataFrame/Series
            Absolute value of each element.

        Examples
        --------
        Absolute numeric values in a Series

        >>> s = cudf.Series([-1.10, 2, -3.33, 4])
        >>> s.abs()
        0    1.10
        1    2.00
        2    3.33
        3    4.00
        dtype: float64
        """
        return self._unaryop("abs")

    # Rounding
    def ceil(self):
        """
        Rounds each value upward to the smallest integral value not less
        than the original.

        Returns
        -------
        DataFrame or Series
            Ceiling value of each element.

        Examples
        --------
        >>> import cudf
        >>> series = cudf.Series([1.1, 2.8, 3.5, 4.5])
        >>> series
        0    1.1
        1    2.8
        2    3.5
        3    4.5
        dtype: float64
        >>> series.ceil()
        0    2.0
        1    3.0
        2    4.0
        3    5.0
        dtype: float64
        """

        warnings.warn(
            "Series.ceil and DataFrame.ceil are deprecated and will be \
                removed in the future",
            DeprecationWarning,
        )

        return self._unaryop("ceil")

    def floor(self):
        """Rounds each value downward to the largest integral value not greater
        than the original.

        Returns
        -------
        DataFrame or Series
            Flooring value of each element.

        Examples
        --------
        >>> import cudf
        >>> series = cudf.Series([-1.9, 2, 0.2, 1.5, 0.0, 3.0])
        >>> series
        0   -1.9
        1    2.0
        2    0.2
        3    1.5
        4    0.0
        5    3.0
        dtype: float64
        >>> series.floor()
        0   -2.0
        1    2.0
        2    0.0
        3    1.0
        4    0.0
        5    3.0
        dtype: float64
        """

        warnings.warn(
            "Series.ceil and DataFrame.ceil are deprecated and will be \
                removed in the future",
            DeprecationWarning,
        )

        return self._unaryop("floor")

    def scale(self):
        """
        Scale values to [0, 1] in float64

        Returns
        -------
        DataFrame or Series
            Values scaled to [0, 1].

        Examples
        --------
        >>> import cudf
        >>> series = cudf.Series([10, 11, 12, 0.5, 1])
        >>> series
        0    10.0
        1    11.0
        2    12.0
        3     0.5
        4     1.0
        dtype: float64
        >>> series.scale()
        0    0.826087
        1    0.913043
        2    1.000000
        3    0.000000
        4    0.043478
        dtype: float64
        """
        vmin = self.min()
        vmax = self.max()
        scaled = (self - vmin) / (vmax - vmin)
        scaled._index = self._index.copy(deep=False)
        return scaled

    def _merge(
        self,
        right,
        on=None,
        left_on=None,
        right_on=None,
        left_index=False,
        right_index=False,
        how="inner",
        sort=False,
        indicator=False,
        suffixes=("_x", "_y"),
    ):
        lhs, rhs = self, right
        merge_cls = Merge
        if how == "right":
            # Merge doesn't support right, so just swap
            how = "left"
            lhs, rhs = right, self
            left_on, right_on = right_on, left_on
            left_index, right_index = right_index, left_index
            suffixes = (suffixes[1], suffixes[0])
        elif how in {"leftsemi", "leftanti"}:
            merge_cls = MergeSemi

        # TODO: the two isinstance checks below indicates that `_merge` should
        # not be defined in `Frame`, but in `IndexedFrame`.
        return merge_cls(
            lhs,
            rhs,
            on=on,
            left_on=left_on,
            right_on=right_on,
            left_index=left_index,
            right_index=right_index,
            lhs_is_index=isinstance(lhs, cudf.core._base_index.BaseIndex),
            rhs_is_index=isinstance(rhs, cudf.core._base_index.BaseIndex),
            how=how,
            sort=sort,
            indicator=indicator,
            suffixes=suffixes,
        ).perform_merge()

    def _is_sorted(self, ascending=None, null_position=None):
        """
        Returns a boolean indicating whether the data of the Frame are sorted
        based on the parameters given. Does not account for the index.

        Parameters
        ----------
        self : Frame
            Frame whose columns are to be checked for sort order
        ascending : None or list-like of booleans
            None or list-like of boolean values indicating expected sort order
            of each column. If list-like, size of list-like must be
            len(columns). If None, all columns expected sort order is set to
            ascending. False (0) - ascending, True (1) - descending.
        null_position : None or list-like of booleans
            None or list-like of boolean values indicating desired order of
            nulls compared to other elements. If list-like, size of list-like
            must be len(columns). If None, null order is set to before. False
            (0) - before, True (1) - after.

        Returns
        -------
        returns : boolean
            Returns True, if sorted as expected by ``ascending`` and
            ``null_position``, False otherwise.
        """
        return libcudf.sort.is_sorted(
            self, ascending=ascending, null_position=null_position
        )

    def _split(self, splits, keep_index=True):
        results = libcudf.copying.table_split(
            self, splits, keep_index=keep_index
        )
        return [self.__class__._from_data(*result) for result in results]

    def _encode(self):
        data, index, indices = libcudf.transform.table_encode(self)
        for name, col in data.items():
            data[name] = col._with_type_metadata(self._data[name].dtype)
        keys = self.__class__._from_data(data, index)
        return keys, indices

    def _unaryop(self, op):
        data_columns = (col.unary_operator(op) for col in self._columns)
        return self.__class__._from_data(
            zip(self._column_names, data_columns), self._index
        )

    def _binaryop(
        self,
        other: T,
        fn: str,
        fill_value: Any = None,
        reflect: bool = False,
        *args,
        **kwargs,
    ) -> Frame:
        """Perform a binary operation between two frames.

        Parameters
        ----------
        other : Frame
            The second operand.
        fn : str
            The operation to perform.
        fill_value : Any, default None
            The value to replace null values with. If ``None``, nulls are not
            filled before the operation.
        reflect : bool, default False
            If ``True``, swap the order of the operands. See
            https://docs.python.org/3/reference/datamodel.html#object.__ror__
            for more information on when this is necessary.

        Returns
        -------
        Frame
            A new instance containing the result of the operation.
        """
        raise NotImplementedError

    @classmethod
    def _colwise_binop(
        cls,
        operands: Dict[Optional[str], Tuple[ColumnBase, Any, bool, Any]],
        fn: str,
    ):
        """Implement binary ops between two frame-like objects.

        Binary operations for Frames can be reduced to a sequence of binary
        operations between column-like objects. Different types of frames need
        to preprocess different inputs, so subclasses should implement binary
        operations as a preprocessing step that calls this method.

        Parameters
        ----------
        operands : Dict[Optional[str], Tuple[ColumnBase, Any, bool, Any]]
            A mapping from column names to a tuple containing left and right
            operands as well as a boolean indicating whether or not to reflect
            an operation and fill value for nulls.
        fn : str
            The operation to perform.

        Returns
        -------
        Dict[ColumnBase]
            A dict of columns constructed from the result of performing the
            requested operation on the operands.
        """

        # Now actually perform the binop on the columns in left and right.
        output = {}
        for (
            col,
            (left_column, right_column, reflect, fill_value),
        ) in operands.items():

            # Handle object columns that are empty or
            # all nulls when performing binary operations
            if (
                left_column.dtype == "object"
                and left_column.null_count == len(left_column)
                and fill_value is None
            ):
                if fn in (
                    "add",
                    "sub",
                    "mul",
                    "mod",
                    "pow",
                    "truediv",
                    "floordiv",
                ):
                    output[col] = left_column
                elif fn in ("eq", "lt", "le", "gt", "ge"):
                    output[col] = left_column.notnull()
                elif fn == "ne":
                    output[col] = left_column.isnull()
                continue

            if right_column is cudf.NA:
                right_column = cudf.Scalar(
                    right_column, dtype=left_column.dtype
                )
            elif not isinstance(right_column, ColumnBase):
                right_column = left_column.normalize_binop_value(right_column)

            fn_apply = fn
            if fn == "truediv":
                # Decimals in libcudf don't support truediv, see
                # https://github.com/rapidsai/cudf/pull/7435 for explanation.
                if is_decimal_dtype(left_column.dtype):
                    fn_apply = "div"

                # Division with integer types results in a suitable float.
                truediv_type = {
                    np.int8: np.float32,
                    np.int16: np.float32,
                    np.int32: np.float32,
                    np.int64: np.float64,
                    np.uint8: np.float32,
                    np.uint16: np.float32,
                    np.uint32: np.float64,
                    np.uint64: np.float64,
                    np.bool_: np.float32,
                }.get(left_column.dtype.type)
                if truediv_type is not None:
                    left_column = left_column.astype(truediv_type)

            output_mask = None
            if fill_value is not None:
                if is_scalar(right_column):
                    if left_column.nullable:
                        left_column = left_column.fillna(fill_value)
                else:
                    # If both columns are nullable, pandas semantics dictate
                    # that nulls that are present in both left_column and
                    # right_column are not filled.
                    if left_column.nullable and right_column.nullable:
                        lmask = as_column(left_column.nullmask)
                        rmask = as_column(right_column.nullmask)
                        output_mask = (lmask | rmask).data
                        left_column = left_column.fillna(fill_value)
                        right_column = right_column.fillna(fill_value)
                    elif left_column.nullable:
                        left_column = left_column.fillna(fill_value)
                    elif right_column.nullable:
                        right_column = right_column.fillna(fill_value)

            # For bitwise operations we must verify whether the input column
            # types are valid, and if so, whether we need to coerce the output
            # columns to booleans.
            coerce_to_bool = False
            if fn_apply in {"and", "or", "xor"}:
                err_msg = (
                    f"Operation 'bitwise {fn_apply}' not supported between "
                    f"{left_column.dtype.type.__name__} and {{}}"
                )
                if right_column is None:
                    raise TypeError(err_msg.format(type(None)))

                try:
                    left_is_bool = issubdtype(left_column.dtype, np.bool_)
                    right_is_bool = issubdtype(right_column.dtype, np.bool_)
                except TypeError:
                    raise TypeError(err_msg.format(type(right_column)))

                coerce_to_bool = left_is_bool or right_is_bool

                if not (
                    (left_is_bool or issubdtype(left_column.dtype, np.integer))
                    and (
                        right_is_bool
                        or issubdtype(right_column.dtype, np.integer)
                    )
                ):
                    raise TypeError(
                        err_msg.format(right_column.dtype.type.__name__)
                    )

            outcol = (
                left_column.binary_operator(
                    fn_apply, right_column, reflect=reflect
                )
                if right_column is not None
                else column_empty(
                    left_column.size, left_column.dtype, masked=True
                )
            )

            if output_mask is not None:
                outcol = outcol.set_mask(output_mask)

            if coerce_to_bool:
                outcol = outcol.astype(np.bool_)

            output[col] = outcol

        return output

    def dot(self, other, reflect=False):
        """
        Get dot product of frame and other, (binary operator `dot`).

        Among flexible wrappers (`add`, `sub`, `mul`, `div`, `mod`, `pow`,
        `dot`) to arithmetic operators: `+`, `-`, `*`, `/`, `//`, `%`, `**`,
        `@`.

        Parameters
        ----------
        other : Sequence, Series, or DataFrame
            Any multiple element data structure, or list-like object.
        reflect : bool, default False
            If ``True``, swap the order of the operands. See
            https://docs.python.org/3/reference/datamodel.html#object.__ror__
            for more information on when this is necessary.

        Returns
        -------
        scalar, Series, or DataFrame
            The result of the operation.

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame([[1, 2, 3, 4],
        ...                      [5, 6, 7, 8]])
        >>> df @ df.T
            0    1
        0  30   70
        1  70  174
        >>> s = cudf.Series([1, 1, 1, 1])
        >>> df @ s
        0    10
        1    26
        dtype: int64
        >>> [1, 2, 3, 4] @ s
        10
        """
        lhs = self.values
        if isinstance(other, Frame):
            rhs = other.values
        elif isinstance(other, cupy.ndarray):
            rhs = other
        elif isinstance(
            other, (abc.Sequence, np.ndarray, pd.DataFrame, pd.Series)
        ):
            rhs = cupy.asarray(other)
        else:
            return NotImplemented
        if reflect:
            lhs, rhs = rhs, lhs

        result = lhs.dot(rhs)
        if len(result.shape) == 1:
            return cudf.Series(result)
        if len(result.shape) == 2:
            return cudf.DataFrame(result)
        return result.item()

    # Binary arithmetic operations.
    def __add__(self, other):
        return self._binaryop(other, "add")

    def __radd__(self, other):
        return self._binaryop(other, "add", reflect=True)

    def __sub__(self, other):
        return self._binaryop(other, "sub")

    def __rsub__(self, other):
        return self._binaryop(other, "sub", reflect=True)

    def __matmul__(self, other):
        return self.dot(other)

    def __rmatmul__(self, other):
        return self.dot(other, reflect=True)

    def __mul__(self, other):
        return self._binaryop(other, "mul")

    def __rmul__(self, other):
        return self._binaryop(other, "mul", reflect=True)

    def __mod__(self, other):
        return self._binaryop(other, "mod")

    def __rmod__(self, other):
        return self._binaryop(other, "mod", reflect=True)

    def __pow__(self, other):
        return self._binaryop(other, "pow")

    def __rpow__(self, other):
        return self._binaryop(other, "pow", reflect=True)

    def __floordiv__(self, other):
        return self._binaryop(other, "floordiv")

    def __rfloordiv__(self, other):
        return self._binaryop(other, "floordiv", reflect=True)

    def __truediv__(self, other):
        return self._binaryop(other, "truediv")

    def __rtruediv__(self, other):
        return self._binaryop(other, "truediv", reflect=True)

    def __and__(self, other):
        return self._binaryop(other, "and")

    def __or__(self, other):
        return self._binaryop(other, "or")

    def __xor__(self, other):
        return self._binaryop(other, "xor")

    # Binary rich comparison operations.
    def __eq__(self, other):
        return self._binaryop(other, "eq")

    def __ne__(self, other):
        return self._binaryop(other, "ne")

    def __lt__(self, other):
        return self._binaryop(other, "lt")

    def __le__(self, other):
        return self._binaryop(other, "le")

    def __gt__(self, other):
        return self._binaryop(other, "gt")

    def __ge__(self, other):
        return self._binaryop(other, "ge")

    # Unary logical operators
    def __neg__(self):
        return -1 * self

    def __pos__(self):
        return self.copy(deep=True)

    def __abs__(self):
        return self._unaryop("abs")

    # Reductions
    @classmethod
    def _get_axis_from_axis_arg(cls, axis):
        try:
            return cls._SUPPORT_AXIS_LOOKUP[axis]
        except KeyError:
            raise ValueError(f"No axis named {axis} for object type {cls}")

    def _reduce(self, *args, **kwargs):
        raise NotImplementedError(
            f"Reductions are not supported for objects of type {type(self)}."
        )

    def min(
        self, axis=None, skipna=None, level=None, numeric_only=None, **kwargs,
    ):
        """
        Return the minimum of the values in the DataFrame.

        Parameters
        ----------
        axis: {index (0), columns(1)}
            Axis for the function to be applied on.
        skipna: bool, default True
            Exclude NA/null values when computing the result.
        level: int or level name, default None
            If the axis is a MultiIndex (hierarchical), count along a
            particular level, collapsing into a Series.
        numeric_only: bool, default None
            Include only float, int, boolean columns. If None, will attempt to
            use everything, then use only numeric data.

        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported are `level`, `numeric_only`.

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> df.min()
        a    1
        b    7
        dtype: int64
        """
        return self._reduce(
            "min",
            axis=axis,
            skipna=skipna,
            level=level,
            numeric_only=numeric_only,
            **kwargs,
        )

    def max(
        self, axis=None, skipna=None, level=None, numeric_only=None, **kwargs,
    ):
        """
        Return the maximum of the values in the DataFrame.

        Parameters
        ----------
        axis: {index (0), columns(1)}
            Axis for the function to be applied on.
        skipna: bool, default True
            Exclude NA/null values when computing the result.
        level: int or level name, default None
            If the axis is a MultiIndex (hierarchical), count along a
            particular level, collapsing into a Series.
        numeric_only: bool, default None
            Include only float, int, boolean columns. If None, will attempt to
            use everything, then use only numeric data.

        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported are `level`, `numeric_only`.

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> df.max()
        a     4
        b    10
        dtype: int64
        """
        return self._reduce(
            "max",
            axis=axis,
            skipna=skipna,
            level=level,
            numeric_only=numeric_only,
            **kwargs,
        )

    def sum(
        self,
        axis=None,
        skipna=None,
        dtype=None,
        level=None,
        numeric_only=None,
        min_count=0,
        **kwargs,
    ):
        """
        Return sum of the values in the DataFrame.

        Parameters
        ----------

        axis: {index (0), columns(1)}
            Axis for the function to be applied on.
        skipna: bool, default True
            Exclude NA/null values when computing the result.
        dtype: data type
            Data type to cast the result to.
        min_count: int, default 0
            The required number of valid values to perform the operation.
            If fewer than min_count non-NA values are present the result
            will be NA.

            The default being 0. This means the sum of an all-NA or empty
            Series is 0, and the product of an all-NA or empty Series is 1.

        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported are `level`, `numeric_only`.

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> df.sum()
        a    10
        b    34
        dtype: int64
        """
        return self._reduce(
            "sum",
            axis=axis,
            skipna=skipna,
            dtype=dtype,
            level=level,
            numeric_only=numeric_only,
            min_count=min_count,
            **kwargs,
        )

    def product(
        self,
        axis=None,
        skipna=None,
        dtype=None,
        level=None,
        numeric_only=None,
        min_count=0,
        **kwargs,
    ):
        """
        Return product of the values in the DataFrame.

        Parameters
        ----------

        axis: {index (0), columns(1)}
            Axis for the function to be applied on.
        skipna: bool, default True
            Exclude NA/null values when computing the result.
        dtype: data type
            Data type to cast the result to.
        min_count: int, default 0
            The required number of valid values to perform the operation.
            If fewer than min_count non-NA values are present the result
            will be NA.

            The default being 0. This means the sum of an all-NA or empty
            Series is 0, and the product of an all-NA or empty Series is 1.

        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported are level`, `numeric_only`.

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> df.product()
        a      24
        b    5040
        dtype: int64
        """
        axis = self._get_axis_from_axis_arg(axis)
        return self._reduce(
            # cuDF columns use "product" as the op name, but cupy uses "prod"
            # and we need cupy if axis == 1.
            "product" if axis == 0 else "prod",
            axis=axis,
            skipna=skipna,
            dtype=dtype,
            level=level,
            numeric_only=numeric_only,
            min_count=min_count,
            **kwargs,
        )

    # Alias for pandas compatibility.
    prod = product

    def mean(
        self, axis=None, skipna=None, level=None, numeric_only=None, **kwargs
    ):
        """
        Return the mean of the values for the requested axis.

        Parameters
        ----------
        axis : {0 or 'index', 1 or 'columns'}
            Axis for the function to be applied on.
        skipna : bool, default True
            Exclude NA/null values when computing the result.
        level : int or level name, default None
            If the axis is a MultiIndex (hierarchical), count along a
            particular level, collapsing into a Series.
        numeric_only : bool, default None
            Include only float, int, boolean columns. If None, will attempt to
            use everything, then use only numeric data. Not implemented for
            Series.
        **kwargs
            Additional keyword arguments to be passed to the function.

        Returns
        -------
        mean : Series or DataFrame (if level specified)

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> df.mean()
        a    2.5
        b    8.5
        dtype: float64
        """
        return self._reduce(
            "mean",
            axis=axis,
            skipna=skipna,
            level=level,
            numeric_only=numeric_only,
            **kwargs,
        )

    def std(
        self,
        axis=None,
        skipna=None,
        level=None,
        ddof=1,
        numeric_only=None,
        **kwargs,
    ):
        """
        Return sample standard deviation of the DataFrame.

        Normalized by N-1 by default. This can be changed using
        the `ddof` argument

        Parameters
        ----------

        axis: {index (0), columns(1)}
            Axis for the function to be applied on.
        skipna: bool, default True
            Exclude NA/null values. If an entire row/column is NA, the result
            will be NA.
        ddof: int, default 1
            Delta Degrees of Freedom. The divisor used in calculations
            is N - ddof, where N represents the number of elements.

        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported are `level` and
        `numeric_only`

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> df.std()
        a    1.290994
        b    1.290994
        dtype: float64
        """

        return self._reduce(
            "std",
            axis=axis,
            skipna=skipna,
            level=level,
            ddof=ddof,
            numeric_only=numeric_only,
            **kwargs,
        )

    def var(
        self,
        axis=None,
        skipna=None,
        level=None,
        ddof=1,
        numeric_only=None,
        **kwargs,
    ):
        """
        Return unbiased variance of the DataFrame.

        Normalized by N-1 by default. This can be changed using the
        ddof argument

        Parameters
        ----------

        axis: {index (0), columns(1)}
            Axis for the function to be applied on.
        skipna: bool, default True
            Exclude NA/null values. If an entire row/column is NA, the result
            will be NA.
        ddof: int, default 1
            Delta Degrees of Freedom. The divisor used in calculations is
            N - ddof, where N represents the number of elements.

        Returns
        -------
        scalar

        Notes
        -----
        Parameters currently not supported are `level` and
        `numeric_only`

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> df.var()
        a    1.666667
        b    1.666667
        dtype: float64
        """
        return self._reduce(
            "var",
            axis=axis,
            skipna=skipna,
            level=level,
            ddof=ddof,
            numeric_only=numeric_only,
            **kwargs,
        )

    def kurtosis(
        self, axis=None, skipna=None, level=None, numeric_only=None, **kwargs
    ):
        """
        Return Fisher's unbiased kurtosis of a sample.

        Kurtosis obtained using Fisher’s definition of
        kurtosis (kurtosis of normal == 0.0). Normalized by N-1.

        Parameters
        ----------

        axis: {index (0), columns(1)}
            Axis for the function to be applied on.
        skipna: bool, default True
            Exclude NA/null values when computing the result.

        Returns
        -------
        Series or scalar

        Notes
        -----
        Parameters currently not supported are `level` and `numeric_only`

        Examples
        --------
        **Series**

        >>> import cudf
        >>> series = cudf.Series([1, 2, 3, 4])
        >>> series.kurtosis()
        -1.1999999999999904

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> df.kurt()
        a   -1.2
        b   -1.2
        dtype: float64
        """
        if axis not in (0, "index", None):
            raise NotImplementedError("Only axis=0 is currently supported.")

        return self._reduce(
            "kurtosis",
            axis=axis,
            skipna=skipna,
            level=level,
            numeric_only=numeric_only,
            **kwargs,
        )

    # Alias for kurtosis.
    @copy_docstring(kurtosis)
    def kurt(
        self, axis=None, skipna=None, level=None, numeric_only=None, **kwargs
    ):
        return self.kurtosis(
            axis=axis,
            skipna=skipna,
            level=level,
            numeric_only=numeric_only,
            **kwargs,
        )

    def skew(
        self, axis=None, skipna=None, level=None, numeric_only=None, **kwargs
    ):
        """
        Return unbiased Fisher-Pearson skew of a sample.

        Parameters
        ----------
        skipna: bool, default True
            Exclude NA/null values when computing the result.

        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported are `axis`, `level` and
        `numeric_only`

        Examples
        --------
        **Series**

        >>> import cudf
        >>> series = cudf.Series([1, 2, 3, 4, 5, 6, 6])
        >>> series
        0    1
        1    2
        2    3
        3    4
        4    5
        5    6
        6    6
        dtype: int64

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'a': [3, 2, 3, 4], 'b': [7, 8, 10, 10]})
        >>> df.skew()
        a    0.00000
        b   -0.37037
        dtype: float64
        """
        if axis not in (0, "index", None):
            raise NotImplementedError("Only axis=0 is currently supported.")

        return self._reduce(
            "skew",
            axis=axis,
            skipna=skipna,
            level=level,
            numeric_only=numeric_only,
            **kwargs,
        )

    def all(self, axis=0, skipna=True, level=None, **kwargs):
        """
        Return whether all elements are True in DataFrame.

        Parameters
        ----------

        skipna: bool, default True
            Exclude NA/null values. If the entire row/column is NA and
            skipna is True, then the result will be True, as for an
            empty row/column.
            If skipna is False, then NA are treated as True, because
            these are not equal to zero.

        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported are `axis`, `bool_only`, `level`.

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [3, 2, 3, 4], 'b': [7, 0, 10, 10]})
        >>> df.all()
        a     True
        b    False
        dtype: bool
        """
        return self._reduce(
            "all", axis=axis, skipna=skipna, level=level, **kwargs,
        )

    def any(self, axis=0, skipna=True, level=None, **kwargs):
        """
        Return whether any elements is True in DataFrame.

        Parameters
        ----------

        skipna: bool, default True
            Exclude NA/null values. If the entire row/column is NA and
            skipna is True, then the result will be False, as for an
            empty row/column.
            If skipna is False, then NA are treated as True, because
            these are not equal to zero.

        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported are `axis`, `bool_only`, `level`.

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [3, 2, 3, 4], 'b': [7, 0, 10, 10]})
        >>> df.any()
        a    True
        b    True
        dtype: bool
        """
        return self._reduce(
            "any", axis=axis, skipna=skipna, level=level, **kwargs,
        )

    def sum_of_squares(self, dtype=None):
        """Return the sum of squares of values.

        Parameters
        ----------
        dtype: data type
            Data type to cast the result to.

        Returns
        -------
        Series

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame({'a': [3, 2, 3, 4], 'b': [7, 0, 10, 10]})
        >>> df.sum_of_squares()
        a     38
        b    249
        dtype: int64
        """
        return self._reduce("sum_of_squares", dtype=dtype)

    def median(
        self, axis=None, skipna=None, level=None, numeric_only=None, **kwargs
    ):
        """
        Return the median of the values for the requested axis.

        Parameters
        ----------

        skipna : bool, default True
            Exclude NA/null values when computing the result.

        Returns
        -------
        scalar

        Notes
        -----
        Parameters currently not supported are `level` and `numeric_only`.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([10, 25, 3, 25, 24, 6])
        >>> ser
        0    10
        1    25
        2     3
        3    25
        4    24
        5     6
        dtype: int64
        >>> ser.median()
        17.0
        """
        return self._reduce(
            "median",
            axis=axis,
            skipna=skipna,
            level=level,
            numeric_only=numeric_only,
            **kwargs,
        )

    # Scans
    def _scan(self, op, axis=None, skipna=True, cast_to_int=False):
        skipna = True if skipna is None else skipna

        results = {}
        for name, col in self._data.items():
            if skipna:
                result_col = self._data[name].nans_to_nulls()
            else:
                result_col = self._data[name].copy()
                if result_col.has_nulls(include_nan=True):
                    # Workaround as find_first_value doesn't seem to work
                    # incase of bools.
                    first_index = int(
                        result_col.isnull().astype("int8").find_first_value(1)
                    )
                    result_col[first_index:] = None

            if (
                cast_to_int
                and not is_decimal_dtype(result_col.dtype)
                and (
                    np.issubdtype(result_col.dtype, np.integer)
                    or np.issubdtype(result_col.dtype, np.bool_)
                )
            ):
                # For reductions that accumulate a value (e.g. sum, not max)
                # pandas returns an int64 dtype for all int or bool dtypes.
                result_col = result_col.astype(np.int64)
            results[name] = result_col._apply_scan_op(op)
        # TODO: This will work for Index because it's passing self._index
        # (which is None), but eventually we may want to remove that parameter
        # for Index._from_data and simplify.
        return self._from_data(results, index=self._index)

    def cummin(self, axis=None, skipna=True, *args, **kwargs):
        """
        Return cumulative minimum of the Series or DataFrame.

        Parameters
        ----------

        axis: {index (0), columns(1)}
            Axis for the function to be applied on.
        skipna: bool, default True
            Exclude NA/null values. If an entire row/column is NA,
            the result will be NA.

        Returns
        -------
        Series or DataFrame

        Examples
        --------
        **Series**

        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.cummin()
        0    1
        1    1
        2    1
        3    1
        4    1

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> df.cummin()
           a  b
        0  1  7
        1  1  7
        2  1  7
        3  1  7
        """
        return self._scan("min", axis=axis, skipna=skipna, *args, **kwargs)

    def cummax(self, axis=None, skipna=True, *args, **kwargs):
        """
        Return cumulative maximum of the Series or DataFrame.

        Parameters
        ----------

        axis: {index (0), columns(1)}
            Axis for the function to be applied on.
        skipna: bool, default True
            Exclude NA/null values. If an entire row/column is NA,
            the result will be NA.

        Returns
        -------
        Series or DataFrame

        Examples
        --------
        **Series**

        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.cummax()
        0    1
        1    5
        2    5
        3    5
        4    5

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> df.cummax()
           a   b
        0  1   7
        1  2   8
        2  3   9
        3  4  10
        """
        return self._scan("max", axis=axis, skipna=skipna, *args, **kwargs)

    def cumsum(self, axis=None, skipna=True, *args, **kwargs):
        """
        Return cumulative sum of the Series or DataFrame.

        Parameters
        ----------

        axis: {index (0), columns(1)}
            Axis for the function to be applied on.
        skipna: bool, default True
            Exclude NA/null values. If an entire row/column is NA,
            the result will be NA.


        Returns
        -------
        Series or DataFrame

        Examples
        --------
        **Series**

        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.cumsum()
        0    1
        1    6
        2    8
        3    12
        4    15

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> s.cumsum()
            a   b
        0   1   7
        1   3  15
        2   6  24
        3  10  34
        """
        return self._scan(
            "sum", axis=axis, skipna=skipna, cast_to_int=True, *args, **kwargs
        )

    def cumprod(self, axis=None, skipna=True, *args, **kwargs):
        """
        Return cumulative product of the Series or DataFrame.

        Parameters
        ----------

        axis: {index (0), columns(1)}
            Axis for the function to be applied on.
        skipna: bool, default True
            Exclude NA/null values. If an entire row/column is NA,
            the result will be NA.

        Returns
        -------
        Series or DataFrame

        Examples
        --------
        **Series**

        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.cumprod()
        0    1
        1    5
        2    10
        3    40
        4    120

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'a': [1, 2, 3, 4], 'b': [7, 8, 9, 10]})
        >>> s.cumprod()
            a     b
        0   1     7
        1   2    56
        2   6   504
        3  24  5040
        """
        return self._scan(
            "prod", axis=axis, skipna=skipna, cast_to_int=True, *args, **kwargs
        )

    @ioutils.doc_to_json()
    def to_json(self, path_or_buf=None, *args, **kwargs):
        """{docstring}"""

        return cudf.io.json.to_json(
            self, path_or_buf=path_or_buf, *args, **kwargs
        )

    @ioutils.doc_to_hdf()
    def to_hdf(self, path_or_buf, key, *args, **kwargs):
        """{docstring}"""

        cudf.io.hdf.to_hdf(path_or_buf, key, self, *args, **kwargs)

    @ioutils.doc_to_dlpack()
    def to_dlpack(self):
        """{docstring}"""

        return cudf.io.dlpack.to_dlpack(self)

    def to_string(self):
        """
        Convert to string

        cuDF uses Pandas internals for efficient string formatting.
        Set formatting options using pandas string formatting options and
        cuDF objects will print identically to Pandas objects.

        cuDF supports `null/None` as a value in any column type, which
        is transparently supported during this output process.

        Examples
        --------
        >>> import cudf
        >>> df = cudf.DataFrame()
        >>> df['key'] = [0, 1, 2]
        >>> df['val'] = [float(i + 10) for i in range(3)]
        >>> df.to_string()
        '   key   val\\n0    0  10.0\\n1    1  11.0\\n2    2  12.0'
        """
        return self.__repr__()

    def __str__(self):
        return self.to_string()

    def __deepcopy__(self, memo):
        return self.copy(deep=True)

    def __copy__(self):
        return self.copy(deep=False)

    def head(self, n=5):
        """
        Return the first `n` rows.
        This function returns the first `n` rows for the object based
        on position. It is useful for quickly testing if your object
        has the right type of data in it.
        For negative values of `n`, this function returns all rows except
        the last `n` rows, equivalent to ``df[:-n]``.

        Parameters
        ----------
        n : int, default 5
            Number of rows to select.

        Returns
        -------
        DataFrame or Series
            The first `n` rows of the caller object.

        See Also
        --------
        Frame.tail: Returns the last `n` rows.

        Examples
        --------

        **Series**

        >>> ser = cudf.Series(['alligator', 'bee', 'falcon',
        ... 'lion', 'monkey', 'parrot', 'shark', 'whale', 'zebra'])
        >>> ser
        0    alligator
        1          bee
        2       falcon
        3         lion
        4       monkey
        5       parrot
        6        shark
        7        whale
        8        zebra
        dtype: object

        Viewing the first 5 lines

        >>> ser.head()
        0    alligator
        1          bee
        2       falcon
        3         lion
        4       monkey
        dtype: object

        Viewing the first `n` lines (three in this case)

        >>> ser.head(3)
        0    alligator
        1          bee
        2       falcon
        dtype: object

        For negative values of `n`

        >>> ser.head(-3)
        0    alligator
        1          bee
        2       falcon
        3         lion
        4       monkey
        5       parrot
        dtype: object

        **DataFrame**

        >>> df = cudf.DataFrame()
        >>> df['key'] = [0, 1, 2, 3, 4]
        >>> df['val'] = [float(i + 10) for i in range(5)]  # insert column
        >>> df.head(2)
           key   val
        0    0  10.0
        1    1  11.0
        """
        return self.iloc[:n]

    def tail(self, n=5):
        """
        Returns the last n rows as a new DataFrame or Series

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame()
        >>> df['key'] = [0, 1, 2, 3, 4]
        >>> df['val'] = [float(i + 10) for i in range(5)]  # insert column
        >>> df.tail(2)
           key   val
        3    3  13.0
        4    4  14.0

        **Series**

        >>> import cudf
        >>> ser = cudf.Series([4, 3, 2, 1, 0])
        >>> ser.tail(2)
        3    1
        4    0
        """
        if n == 0:
            return self.iloc[0:0]

        return self.iloc[-n:]

    @copy_docstring(Rolling)
    def rolling(
        self, window, min_periods=None, center=False, axis=0, win_type=None
    ):
        return Rolling(
            self,
            window,
            min_periods=min_periods,
            center=center,
            axis=axis,
            win_type=win_type,
        )

    def nans_to_nulls(self):
        """
        Convert nans (if any) to nulls

        Returns
        -------
        DataFrame or Series

        Examples
        --------

        **Series**

        >>> import cudf, numpy as np
        >>> series = cudf.Series([1, 2, np.nan, None, 10], nan_as_null=False)
        >>> series
        0     1.0
        1     2.0
        2     NaN
        3    <NA>
        4    10.0
        dtype: float64
        >>> series.nans_to_nulls()
        0     1.0
        1     2.0
        2    <NA>
        3    <NA>
        4    10.0
        dtype: float64

        **DataFrame**

        >>> df = cudf.DataFrame()
        >>> df['a'] = cudf.Series([1, None, np.nan], nan_as_null=False)
        >>> df['b'] = cudf.Series([None, 3.14, np.nan], nan_as_null=False)
        >>> df
              a     b
        0   1.0  <NA>
        1  <NA>  3.14
        2   NaN   NaN
        >>> df.nans_to_nulls()
              a     b
        0   1.0  <NA>
        1  <NA>  3.14
        2  <NA>  <NA>
        """
        return self._from_data(
            {
                name: col.copy().nans_to_nulls()
                for name, col in self._data.items()
            },
            self._index,
        )

    def __invert__(self):
        """Bitwise invert (~) for integral dtypes, logical NOT for bools."""
        return self._from_data(
            {
                name: _apply_inverse_column(col)
                for name, col in self._data.items()
            },
            self._index,
        )

    def add(self, other, axis, level=None, fill_value=None):
        """
        Get Addition of dataframe or series and other, element-wise (binary
        operator `add`).

        Equivalent to ``frame + other``, but with support to substitute a
        ``fill_value`` for missing data in one of the inputs.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> df = cudf.DataFrame({'angles': [0, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> df + 1
                   angles  degrees
        circle          1      361
        triangle        4      181
        rectangle       5      361
        >>> df.add(1)
                   angles  degrees
        circle          1      361
        triangle        4      181
        rectangle       5      361

        **Series**

        >>> a = cudf.Series([1, 1, 1, None], index=['a', 'b', 'c', 'd'])
        >>> b = cudf.Series([1, None, 1, None], index=['a', 'b', 'd', 'e'])
        >>> a.add(b)
        a       2
        b    <NA>
        c    <NA>
        d    <NA>
        e    <NA>
        dtype: int64
        >>> a.add(b, fill_value=0)
        a       2
        b       1
        c       1
        d       1
        e    <NA>
        dtype: int64
        """
        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "add", fill_value)

    def radd(self, other, axis, level=None, fill_value=None):
        """
        Get Addition of dataframe or series and other, element-wise (binary
        operator `radd`).

        Equivalent to ``other + frame``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `add`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [0, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> df + 1
                   angles  degrees
        circle          1      361
        triangle        4      181
        rectangle       5      361
        >>> df.radd(1)
                   angles  degrees
        circle          1      361
        triangle        4      181
        rectangle       5      361

        **Series**

        >>> a = cudf.Series([1, 2, 3, None], index=['a', 'b', 'c', 'd'])
        >>> a
        a       1
        b       2
        c       3
        d    <NA>
        dtype: int64
        >>> b = cudf.Series([1, None, 1, None], index=['a', 'b', 'd', 'e'])
        >>> b
        a       1
        b    <NA>
        d       1
        e    <NA>
        dtype: int64
        >>> a.add(b, fill_value=0)
        a       2
        b       2
        c       3
        d       1
        e    <NA>
        dtype: int64

        """

        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "add", fill_value, reflect=True)

    def subtract(self, other, axis, level=None, fill_value=None):
        """
        Get Subtraction of dataframe or series and other, element-wise (binary
        operator `sub`).

        Equivalent to ``frame - other``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `rsub`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [0, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> df.sub(1)
                   angles  degrees
        circle         -1      359
        triangle        2      179
        rectangle       3      359
        >>> df.sub([1, 2])
                   angles  degrees
        circle         -1      358
        triangle        2      178
        rectangle       3      358

        **Series**

        >>> a = cudf.Series([10, 20, None, 30, None], index=['a', 'b', 'c', 'd', 'e'])
        >>> a
        a      10
        b      20
        c    <NA>
        d      30
        e    <NA>
        dtype: int64
        >>> b = cudf.Series([1, None, 2, 30], index=['a', 'c', 'b', 'd'])
        >>> b
        a       1
        c    <NA>
        b       2
        d      30
        dtype: int64
        >>> a.subtract(b, fill_value=2)
        a       9
        b      18
        c    <NA>
        d       0
        e    <NA>
        dtype: int64

        """  # noqa: E501

        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "sub", fill_value)

    sub = subtract

    def rsub(self, other, axis, level=None, fill_value=None):
        """
        Get Subtraction of dataframe or series and other, element-wise (binary
        operator `rsub`).

        Equivalent to ``other - frame``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `sub`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [0, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> df
                   angles  degrees
        circle          0      360
        triangle        3      180
        rectangle       4      360
        >>> df.rsub(1)
                   angles  degrees
        circle          1     -359
        triangle       -2     -179
        rectangle      -3     -359
        >>> df.rsub([1, 2])
                   angles  degrees
        circle          1     -358
        triangle       -2     -178
        rectangle      -3     -358

        **Series**

        >>> import cudf
        >>> a = cudf.Series([1, 2, 3, None], index=['a', 'b', 'c', 'd'])
        >>> a
        a       1
        b       2
        c       3
        d    <NA>
        dtype: int64
        >>> b = cudf.Series([1, None, 2, None], index=['a', 'b', 'd', 'e'])
        >>> b
        a       1
        b    <NA>
        d       2
        e    <NA>
        dtype: int64
        >>> a.rsub(b, fill_value=10)
        a       0
        b       8
        c       7
        d      -8
        e    <NA>
        dtype: int64
        """
        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "sub", fill_value, reflect=True)

    def multiply(self, other, axis, level=None, fill_value=None):
        """
        Get Multiplication of dataframe or series and other, element-wise
        (binary operator `mul`).

        Equivalent to ``frame * other``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `rmul`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [0, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> other = cudf.DataFrame({'angles': [0, 3, 4]},
        ...                      index=['circle', 'triangle', 'rectangle'])
        >>> df * other
                   angles degrees
        circle          0    <NA>
        triangle        9    <NA>
        rectangle      16    <NA>
        >>> df.mul(other, fill_value=0)
                   angles  degrees
        circle          0        0
        triangle        9        0
        rectangle      16        0

        **Series**

        >>> import cudf
        >>> a = cudf.Series([1, 2, 3, None], index=['a', 'b', 'c', 'd'])
        >>> a
        a       1
        b       2
        c       3
        d    <NA>
        dtype: int64
        >>> b = cudf.Series([1, None, 2, None], index=['a', 'b', 'd', 'e'])
        >>> b
        a       1
        b    <NA>
        d       2
        e    <NA>
        dtype: int64
        >>> a.multiply(b, fill_value=0)
        a       1
        b       0
        c       0
        d       0
        e    <NA>
        dtype: int64

        """

        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "mul", fill_value)

    mul = multiply

    def rmul(self, other, axis, level=None, fill_value=None):
        """
        Get Multiplication of dataframe or series and other, element-wise
        (binary operator `rmul`).

        Equivalent to ``other * frame``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `mul`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [0, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> other = cudf.DataFrame({'angles': [0, 3, 4]},
        ...                      index=['circle', 'triangle', 'rectangle'])
        >>> other * df
                   angles degrees
        circle          0    <NA>
        triangle        9    <NA>
        rectangle      16    <NA>
        >>> df.rmul(other, fill_value=0)
                   angles  degrees
        circle          0        0
        triangle        9        0
        rectangle      16        0

        **Series**

        >>> import cudf
        >>> a = cudf.Series([10, 20, None, 30, 40], index=['a', 'b', 'c', 'd', 'e'])
        >>> a
        a      10
        b      20
        c    <NA>
        d      30
        e      40
        dtype: int64
        >>> b = cudf.Series([None, 1, 20, 5, 4], index=['a', 'b', 'd', 'e', 'f'])
        >>> b
        a    <NA>
        b       1
        d      20
        e       5
        f       4
        dtype: int64
        >>> a.rmul(b, fill_value=2)
        a      20
        b      20
        c    <NA>
        d     600
        e     200
        f       8
        dtype: int64
        """  # noqa E501

        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "mul", fill_value, reflect=True)

    def mod(self, other, axis, level=None, fill_value=None):
        """
        Get Modulo division of dataframe or series and other, element-wise
        (binary operator `mod`).

        Equivalent to ``frame % other``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `rmod`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [0, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> df % 100
                   angles  degrees
        circle          0       60
        triangle        3       80
        rectangle       4       60
        >>> df.mod(100)
                   angles  degrees
        circle          0       60
        triangle        3       80
        rectangle       4       60

        **Series**

        >>> import cudf
        >>> series = cudf.Series([10, 20, 30])
        >>> series
        0    10
        1    20
        2    30
        dtype: int64
        >>> series.mod(4)
        0    2
        1    0
        2    2
        dtype: int64


        """
        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "mod", fill_value)

    def rmod(self, other, axis, level=None, fill_value=None):
        """
        Get Modulo division of dataframe or series and other, element-wise
        (binary operator `rmod`).

        Equivalent to ``other % frame``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `mod`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [1, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> 100 % df
                   angles  degrees
        circle          0      100
        triangle        1      100
        rectangle       0      100
        >>> df.rmod(100)
                   angles  degrees
        circle          0      100
        triangle        1      100
        rectangle       0      100

        **Series**

        >>> import cudf
        >>> a = cudf.Series([10, 20, None, 30, 40], index=['a', 'b', 'c', 'd', 'e'])
        >>> a
        a      10
        b      20
        c    <NA>
        d      30
        e      40
        dtype: int64
        >>> b = cudf.Series([None, 1, 20, 5, 4], index=['a', 'b', 'd', 'e', 'f'])
        >>> b
        a    <NA>
        b       1
        d      20
        e       5
        f       4
        dtype: int64
        >>> a.rmod(b, fill_value=10)
        a       0
        b       1
        c    <NA>
        d      20
        e       5
        f       4
        dtype: int64
        """  # noqa E501

        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "mod", fill_value, reflect=True)

    def pow(self, other, axis, level=None, fill_value=None):
        """
        Get Exponential power of dataframe series and other, element-wise
        (binary operator `pow`).

        Equivalent to ``frame ** other``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `rpow`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [1, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> df ** 2
                   angles  degrees
        circle          0   129600
        triangle        9    32400
        rectangle      16   129600
        >>> df.pow(2)
                   angles  degrees
        circle          0   129600
        triangle        9    32400
        rectangle      16   129600

        **Series**

        >>> import cudf
        >>> a = cudf.Series([1, 2, 3, None], index=['a', 'b', 'c', 'd'])
        >>> a
        a       1
        b       2
        c       3
        d    <NA>
        dtype: int64
        >>> b = cudf.Series([10, None, 12, None], index=['a', 'b', 'd', 'e'])
        >>> b
        a      10
        b    <NA>
        d      12
        e    <NA>
        dtype: int64
        >>> a.pow(b, fill_value=0)
        a       1
        b       1
        c       1
        d       0
        e    <NA>
        dtype: int64
        """

        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "pow", fill_value)

    def rpow(self, other, axis, level=None, fill_value=None):
        """
        Get Exponential power of dataframe or series and other, element-wise
        (binary operator `pow`).

        Equivalent to ``other ** frame``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `pow`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [1, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> 1 ** df
                   angles  degrees
        circle          1        1
        triangle        1        1
        rectangle       1        1
        >>> df.rpow(1)
                   angles  degrees
        circle          1        1
        triangle        1        1
        rectangle       1        1

        **Series**

        >>> import cudf
        >>> a = cudf.Series([1, 2, 3, None], index=['a', 'b', 'c', 'd'])
        >>> a
        a       1
        b       2
        c       3
        d    <NA>
        dtype: int64
        >>> b = cudf.Series([10, None, 12, None], index=['a', 'b', 'd', 'e'])
        >>> b
        a      10
        b    <NA>
        d      12
        e    <NA>
        dtype: int64
        >>> a.rpow(b, fill_value=0)
        a      10
        b       0
        c       0
        d       1
        e    <NA>
        dtype: int64
        """

        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "pow", fill_value, reflect=True)

    def floordiv(self, other, axis, level=None, fill_value=None):
        """
        Get Integer division of dataframe or series and other, element-wise
        (binary operator `floordiv`).

        Equivalent to ``frame // other``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `rfloordiv`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [1, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> df.floordiv(2)
                   angles  degrees
        circle          0      180
        triangle        1       90
        rectangle       2      180
        >>> df // 2
                   angles  degrees
        circle          0      180
        triangle        1       90
        rectangle       2      180

        **Series**

        >>> import cudf
        >>> a = cudf.Series([1, 1, 1, None], index=['a', 'b', 'c', 'd'])
        >>> a
        a       1
        b       1
        c       1
        d    <NA>
        dtype: int64
        >>> b = cudf.Series([1, None, 1, None], index=['a', 'b', 'd', 'e'])
        >>> b
        a       1
        b    <NA>
        d       1
        e    <NA>
        dtype: int64
        >>> a.floordiv(b)
        a       1
        b    <NA>
        c    <NA>
        d    <NA>
        e    <NA>
        dtype: int64
        """

        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "floordiv", fill_value)

    def rfloordiv(self, other, axis, level=None, fill_value=None):
        """
        Get Integer division of dataframe or series and other, element-wise
        (binary operator `rfloordiv`).

        Equivalent to ``other // dataframe``, but with support to substitute
        a fill_value for missing data in one of the inputs. With reverse
        version, `floordiv`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'col1': [10, 11, 23],
        ... 'col2': [101, 122, 321]})
        >>> df
           col1  col2
        0    10   101
        1    11   122
        2    23   321
        >>> df.rfloordiv(df)
           col1  col2
        0     1     1
        1     1     1
        2     1     1
        >>> df.rfloordiv(200)
           col1  col2
        0    20     1
        1    18     1
        2     8     0
        >>> df.rfloordiv(100)
           col1  col2
        0    10     0
        1     9     0
        2     4     0

        **Series**

        >>> import cudf
        >>> s = cudf.Series([1, 2, 10, 17])
        >>> s
        0     1
        1     2
        2    10
        3    17
        dtype: int64
        >>> s.rfloordiv(100)
        0    100
        1     50
        2     10
        3      5
        dtype: int64
        >>> s = cudf.Series([10, 20, None])
        >>> s
        0      10
        1      20
        2    <NA>
        dtype: int64
        >>> s.rfloordiv(200)
        0      20
        1      10
        2    <NA>
        dtype: int64
        >>> s.rfloordiv(200, fill_value=2)
        0     20
        1     10
        2    100
        dtype: int64
        """

        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "floordiv", fill_value, reflect=True)

    def truediv(self, other, axis, level=None, fill_value=None):
        """
        Get Floating division of dataframe or series and other, element-wise
        (binary operator `truediv`).

        Equivalent to ``frame / other``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `rtruediv`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [0, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> df.truediv(10)
                   angles  degrees
        circle        0.0     36.0
        triangle      0.3     18.0
        rectangle     0.4     36.0
        >>> df.div(10)
                   angles  degrees
        circle        0.0     36.0
        triangle      0.3     18.0
        rectangle     0.4     36.0
        >>> df / 10
                   angles  degrees
        circle        0.0     36.0
        triangle      0.3     18.0
        rectangle     0.4     36.0

        **Series**

        >>> import cudf
        >>> a = cudf.Series([1, 10, 20, None], index=['a', 'b', 'c', 'd'])
        >>> a
        a       1
        b      10
        c      20
        d    <NA>
        dtype: int64
        >>> b = cudf.Series([1, None, 2, None], index=['a', 'b', 'd', 'e'])
        >>> b
        a       1
        b    <NA>
        d       2
        e    <NA>
        dtype: int64
        >>> a.truediv(b, fill_value=0)
        a     1.0
        b     Inf
        c     Inf
        d     0.0
        e    <NA>
        dtype: float64
        """

        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "truediv", fill_value)

    # Alias for truediv
    div = truediv
    divide = truediv

    def rtruediv(self, other, axis, level=None, fill_value=None):
        """
        Get Floating division of dataframe or series and other, element-wise
        (binary operator `rtruediv`).

        Equivalent to ``other / frame``, but with support to substitute a
        fill_value for missing data in one of the inputs. With reverse
        version, `truediv`.

        Parameters
        ----------

        other : scalar, sequence, Series, or DataFrame
            Any single or multiple element data structure, or list-like object.
        axis : int or string
            Only ``0`` is supported for series, ``1`` or ``columns`` supported
            for dataframe
        fill_value  : float or None, default None
            Fill existing missing (NaN) values, and any new element needed
            for successful DataFrame alignment, with this value before
            computation. If data in both corresponding DataFrame locations
            is missing the result will be missing.

        Returns
        -------
        DataFrame or Series
            Result of the arithmetic operation.

        Examples
        --------

        **DataFrame**

        >>> import cudf
        >>> df = cudf.DataFrame({'angles': [0, 3, 4],
        ...                    'degrees': [360, 180, 360]},
        ...                   index=['circle', 'triangle', 'rectangle'])
        >>> df
                   angles  degrees
        circle          0      360
        triangle        3      180
        rectangle       4      360
        >>> df.rtruediv(10)
                     angles   degrees
        circle          inf  0.027778
        triangle   3.333333  0.055556
        rectangle  2.500000  0.027778
        >>> df.rdiv(10)
                     angles   degrees
        circle          inf  0.027778
        triangle   3.333333  0.055556
        rectangle  2.500000  0.027778
        >>> 10 / df
                     angles   degrees
        circle          inf  0.027778
        triangle   3.333333  0.055556
        rectangle  2.500000  0.027778

        **Series**

        >>> import cudf
        >>> a = cudf.Series([10, 20, None, 30], index=['a', 'b', 'c', 'd'])
        >>> a
        a      10
        b      20
        c    <NA>
        d      30
        dtype: int64
        >>> b = cudf.Series([1, None, 2, 3], index=['a', 'b', 'd', 'e'])
        >>> b
        a       1
        b    <NA>
        d       2
        e       3
        dtype: int64
        >>> a.rtruediv(b, fill_value=0)
        a            0.1
        b            0.0
        c           <NA>
        d    0.066666667
        e            Inf
        dtype: float64
        """

        if level is not None:
            raise NotImplementedError("level parameter is not supported yet.")

        return self._binaryop(other, "truediv", fill_value, reflect=True)

    # Alias for rtruediv
    rdiv = rtruediv

    def eq(self, other, axis="columns", level=None, fill_value=None):
        """Equal to, element-wise (binary operator eq).

        Parameters
        ----------
        other : Series or scalar value
        fill_value : None or value
            Value to fill nulls with before computation. If data in both
            corresponding Series locations is null the result will be null

        Returns
        -------
        Frame
            The result of the operation.

        Examples
        --------
        **DataFrame**

        >>> left = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'c': [7, 8, 9]}
        ... )
        >>> right = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'd': [10, 12, 12]}
        ... )
        >>> left.eq(right)
        a     b     c     d
        0  True  True  <NA>  <NA>
        1  True  True  <NA>  <NA>
        2  True  True  <NA>  <NA>
        >>> left.eq(right, fill_value=7)
        a     b      c      d
        0  True  True   True  False
        1  True  True  False  False
        2  True  True  False  False

        **Series**

        >>> a = cudf.Series([1, 2, 3, None, 10, 20],
        ...                 index=['a', 'c', 'd', 'e', 'f', 'g'])
        >>> a
        a       1
        c       2
        d       3
        e    <NA>
        f      10
        g      20
        dtype: int64
        >>> b = cudf.Series([-10, 23, -1, None, None],
        ...                 index=['a', 'b', 'c', 'd', 'e'])
        >>> b
        a     -10
        b      23
        c      -1
        d    <NA>
        e    <NA>
        dtype: int64
        >>> a.eq(b, fill_value=2)
        a    False
        b    False
        c    False
        d    False
        e     <NA>
        f    False
        g    False
        dtype: bool
        """
        return self._binaryop(
            other=other, fn="eq", fill_value=fill_value, can_reindex=True
        )

    def ne(self, other, axis="columns", level=None, fill_value=None):
        """Not equal to, element-wise (binary operator ne).

        Parameters
        ----------
        other : Series or scalar value
        fill_value : None or value
            Value to fill nulls with before computation. If data in both
            corresponding Series locations is null the result will be null

        Returns
        -------
        Frame
            The result of the operation.

        Examples
        --------
        **DataFrame**

        >>> left = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'c': [7, 8, 9]}
        ... )
        >>> right = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'd': [10, 12, 12]}
        ... )
        >>> left.ne(right)
        a      b     c     d
        0  False  False  <NA>  <NA>
        1  False  False  <NA>  <NA>
        2  False  False  <NA>  <NA>
        >>> left.ne(right, fill_value=7)
        a      b      c     d
        0  False  False  False  True
        1  False  False   True  True
        2  False  False   True  True

        **Series**

        >>> a = cudf.Series([1, 2, 3, None, 10, 20],
        ...                 index=['a', 'c', 'd', 'e', 'f', 'g'])
        >>> a
        a       1
        c       2
        d       3
        e    <NA>
        f      10
        g      20
        dtype: int64
        >>> b = cudf.Series([-10, 23, -1, None, None],
        ...                 index=['a', 'b', 'c', 'd', 'e'])
        >>> b
        a     -10
        b      23
        c      -1
        d    <NA>
        e    <NA>
        dtype: int64
        >>> a.ne(b, fill_value=2)
        a    True
        b    True
        c    True
        d    True
        e    <NA>
        f    True
        g    True
        dtype: bool
        """  # noqa: E501
        return self._binaryop(
            other=other, fn="ne", fill_value=fill_value, can_reindex=True
        )

    def lt(self, other, axis="columns", level=None, fill_value=None):
        """Less than, element-wise (binary operator lt).

        Parameters
        ----------
        other : Series or scalar value
        fill_value : None or value
            Value to fill nulls with before computation. If data in both
            corresponding Series locations is null the result will be null

        Returns
        -------
        Frame
            The result of the operation.

        Examples
        --------
        **DataFrame**

        >>> left = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'c': [7, 8, 9]}
        ... )
        >>> right = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'd': [10, 12, 12]}
        ... )
        >>> left.lt(right)
        a      b     c     d
        0  False  False  <NA>  <NA>
        1  False  False  <NA>  <NA>
        2  False  False  <NA>  <NA>
        >>> left.lt(right, fill_value=7)
        a      b      c     d
        0  False  False  False  True
        1  False  False  False  True
        2  False  False  False  True

        **Series**

        >>> a = cudf.Series([1, 2, 3, None, 10, 20],
        ...                 index=['a', 'c', 'd', 'e', 'f', 'g'])
        >>> a
        a       1
        c       2
        d       3
        e    <NA>
        f      10
        g      20
        dtype: int64
        >>> b = cudf.Series([-10, 23, -1, None, None],
        ...                 index=['a', 'b', 'c', 'd', 'e'])
        >>> b
        a     -10
        b      23
        c      -1
        d    <NA>
        e    <NA>
        dtype: int64
        >>> a.lt(b, fill_value=-10)
        a    False
        b     True
        c    False
        d    False
        e     <NA>
        f    False
        g    False
        dtype: bool
        """  # noqa: E501
        return self._binaryop(
            other=other, fn="lt", fill_value=fill_value, can_reindex=True
        )

    def le(self, other, axis="columns", level=None, fill_value=None):
        """Less than or equal, element-wise (binary operator le).

        Parameters
        ----------
        other : Series or scalar value
        fill_value : None or value
            Value to fill nulls with before computation. If data in both
            corresponding Series locations is null the result will be null

        Returns
        -------
        Frame
            The result of the operation.

        Examples
        --------
        **DataFrame**

        >>> left = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'c': [7, 8, 9]}
        ... )
        >>> right = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'd': [10, 12, 12]}
        ... )
        >>> left.le(right)
        a     b     c     d
        0  True  True  <NA>  <NA>
        1  True  True  <NA>  <NA>
        2  True  True  <NA>  <NA>
        >>> left.le(right, fill_value=7)
        a     b      c     d
        0  True  True   True  True
        1  True  True  False  True
        2  True  True  False  True

        **Series**

        >>> a = cudf.Series([1, 2, 3, None, 10, 20],
        ...                 index=['a', 'c', 'd', 'e', 'f', 'g'])
        >>> a
        a       1
        c       2
        d       3
        e    <NA>
        f      10
        g      20
        dtype: int64
        >>> b = cudf.Series([-10, 23, -1, None, None],
        ...                 index=['a', 'b', 'c', 'd', 'e'])
        >>> b
        a     -10
        b      23
        c      -1
        d    <NA>
        e    <NA>
        dtype: int64
        >>> a.le(b, fill_value=-10)
        a    False
        b     True
        c    False
        d    False
        e     <NA>
        f    False
        g    False
        dtype: bool
        """  # noqa: E501
        return self._binaryop(
            other=other, fn="le", fill_value=fill_value, can_reindex=True
        )

    def gt(self, other, axis="columns", level=None, fill_value=None):
        """Greater than, element-wise (binary operator gt).

        Parameters
        ----------
        other : Series or scalar value
        fill_value : None or value
            Value to fill nulls with before computation. If data in both
            corresponding Series locations is null the result will be null

        Returns
        -------
        Frame
            The result of the operation.

        Examples
        --------
        **DataFrame**

        >>> left = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'c': [7, 8, 9]}
        ... )
        >>> right = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'd': [10, 12, 12]}
        ... )
        >>> left.gt(right)
        a      b     c     d
        0  False  False  <NA>  <NA>
        1  False  False  <NA>  <NA>
        2  False  False  <NA>  <NA>
        >>> left.gt(right, fill_value=7)
        a      b      c      d
        0  False  False  False  False
        1  False  False   True  False
        2  False  False   True  False

        **Series**

        >>> a = cudf.Series([1, 2, 3, None, 10, 20],
        ...                 index=['a', 'c', 'd', 'e', 'f', 'g'])
        >>> a
        a       1
        c       2
        d       3
        e    <NA>
        f      10
        g      20
        dtype: int64
        >>> b = cudf.Series([-10, 23, -1, None, None],
        ...                 index=['a', 'b', 'c', 'd', 'e'])
        >>> b
        a     -10
        b      23
        c      -1
        d    <NA>
        e    <NA>
        dtype: int64
        >>> a.gt(b)
        a     True
        b    False
        c     True
        d    False
        e    False
        f    False
        g    False
        dtype: bool
        """  # noqa: E501
        return self._binaryop(
            other=other, fn="gt", fill_value=fill_value, can_reindex=True
        )

    def ge(self, other, axis="columns", level=None, fill_value=None):
        """Greater than or equal, element-wise (binary operator ge).

        Parameters
        ----------
        other : Series or scalar value
        fill_value : None or value
            Value to fill nulls with before computation. If data in both
            corresponding Series locations is null the result will be null

        Returns
        -------
        Frame
            The result of the operation.

        Examples
        --------
        **DataFrame**

        >>> left = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'c': [7, 8, 9]}
        ... )
        >>> right = cudf.DataFrame({
        ...     'a': [1, 2, 3],
        ...     'b': [4, 5, 6],
        ...     'd': [10, 12, 12]}
        ... )
        >>> left.ge(right)
        a     b     c     d
        0  True  True  <NA>  <NA>
        1  True  True  <NA>  <NA>
        2  True  True  <NA>  <NA>
        >>> left.ge(right, fill_value=7)
        a     b     c      d
        0  True  True  True  False
        1  True  True  True  False
        2  True  True  True  False

        **Series**

        >>> a = cudf.Series([1, 2, 3, None, 10, 20],
        ...                 index=['a', 'c', 'd', 'e', 'f', 'g'])
        >>> a
        a       1
        c       2
        d       3
        e    <NA>
        f      10
        g      20
        dtype: int64
        >>> b = cudf.Series([-10, 23, -1, None, None],
        ...                 index=['a', 'b', 'c', 'd', 'e'])
        >>> b
        a     -10
        b      23
        c      -1
        d    <NA>
        e    <NA>
        dtype: int64
        >>> a.ge(b)
        a     True
        b    False
        c     True
        d    False
        e    False
        f    False
        g    False
        dtype: bool
        """  # noqa: E501
        return self._binaryop(
            other=other, fn="ge", fill_value=fill_value, can_reindex=True
        )


def _get_replacement_values_for_columns(
    to_replace: Any, value: Any, columns_dtype_map: Dict[Any, Any]
) -> Tuple[Dict[Any, bool], Dict[Any, Any], Dict[Any, Any]]:
    """
    Returns a per column mapping for the values to be replaced, new
    values to be replaced with and if all the values are empty.

    Parameters
    ----------
    to_replace : numeric, str, list-like or dict
        Contains the values to be replaced.
    value : numeric, str, list-like, or dict
        Contains the values to replace `to_replace` with.
    columns_dtype_map : dict
        A column to dtype mapping representing dtype of columns.

    Returns
    -------
    all_na_columns : dict
        A dict mapping of all columns if they contain all na values
    to_replace_columns : dict
        A dict mapping of all columns and the existing values that
        have to be replaced.
    values_columns : dict
        A dict mapping of all columns and the corresponding values
        to be replaced with.
    """
    to_replace_columns: Dict[Any, Any] = {}
    values_columns: Dict[Any, Any] = {}
    all_na_columns: Dict[Any, Any] = {}

    if is_scalar(to_replace) and is_scalar(value):
        to_replace_columns = {col: [to_replace] for col in columns_dtype_map}
        values_columns = {col: [value] for col in columns_dtype_map}
    elif cudf.api.types.is_list_like(to_replace) or isinstance(
        to_replace, ColumnBase
    ):
        if is_scalar(value):
            to_replace_columns = {col: to_replace for col in columns_dtype_map}
            values_columns = {
                col: [value]
                if _is_non_decimal_numeric_dtype(columns_dtype_map[col])
                else cudf.utils.utils.scalar_broadcast_to(
                    value, (len(to_replace),), cudf.dtype(type(value)),
                )
                for col in columns_dtype_map
            }
        elif cudf.api.types.is_list_like(value):
            if len(to_replace) != len(value):
                raise ValueError(
                    f"Replacement lists must be "
                    f"of same length."
                    f" Expected {len(to_replace)}, got {len(value)}."
                )
            else:
                to_replace_columns = {
                    col: to_replace for col in columns_dtype_map
                }
                values_columns = {col: value for col in columns_dtype_map}
        elif cudf.utils.dtypes.is_column_like(value):
            to_replace_columns = {col: to_replace for col in columns_dtype_map}
            values_columns = {col: value for col in columns_dtype_map}
        else:
            raise TypeError(
                "value argument must be scalar, list-like or Series"
            )
    elif _is_series(to_replace):
        if value is None:
            to_replace_columns = {
                col: as_column(to_replace.index) for col in columns_dtype_map
            }
            values_columns = {col: to_replace for col in columns_dtype_map}
        elif is_dict_like(value):
            to_replace_columns = {
                col: to_replace[col]
                for col in columns_dtype_map
                if col in to_replace
            }
            values_columns = {
                col: value[col] for col in to_replace_columns if col in value
            }
        elif is_scalar(value) or _is_series(value):
            to_replace_columns = {
                col: to_replace[col]
                for col in columns_dtype_map
                if col in to_replace
            }
            values_columns = {
                col: [value] if is_scalar(value) else value[col]
                for col in to_replace_columns
                if col in value
            }
        else:
            raise ValueError(
                "Series.replace cannot use dict-like to_replace and non-None "
                "value"
            )
    elif is_dict_like(to_replace):
        if value is None:
            to_replace_columns = {
                col: list(to_replace.keys()) for col in columns_dtype_map
            }
            values_columns = {
                col: list(to_replace.values()) for col in columns_dtype_map
            }
        elif is_dict_like(value):
            to_replace_columns = {
                col: to_replace[col]
                for col in columns_dtype_map
                if col in to_replace
            }
            values_columns = {
                col: value[col] for col in columns_dtype_map if col in value
            }
        elif is_scalar(value) or _is_series(value):
            to_replace_columns = {
                col: to_replace[col]
                for col in columns_dtype_map
                if col in to_replace
            }
            values_columns = {
                col: [value] if is_scalar(value) else value
                for col in columns_dtype_map
                if col in to_replace
            }
        else:
            raise TypeError("value argument must be scalar, dict, or Series")
    else:
        raise TypeError(
            "Expecting 'to_replace' to be either a scalar, array-like, "
            "dict or None, got invalid type "
            f"'{type(to_replace).__name__}'"
        )

    to_replace_columns = {
        key: [value] if is_scalar(value) else value
        for key, value in to_replace_columns.items()
    }
    values_columns = {
        key: [value] if is_scalar(value) else value
        for key, value in values_columns.items()
    }

    for i in to_replace_columns:
        if i in values_columns:
            if isinstance(values_columns[i], list):
                all_na = values_columns[i].count(None) == len(
                    values_columns[i]
                )
            else:
                all_na = False
            all_na_columns[i] = all_na

    return all_na_columns, to_replace_columns, values_columns


def _is_series(obj):
    """
    Checks if the `obj` is of type `cudf.Series`
    instead of checking for isinstance(obj, cudf.Series)
    """
    return isinstance(obj, Frame) and obj.ndim == 1 and obj._index is not None


def _drop_rows_by_labels(
    obj: DataFrameOrSeries,
    labels: Union[ColumnLike, abc.Iterable, str],
    level: Union[int, str],
    errors: str,
) -> DataFrameOrSeries:
    """Remove rows specified by `labels`. If `errors=True`, an error is raised
    if some items in `labels` do not exist in `obj._index`.

    Will raise if level(int) is greater or equal to index nlevels
    """
    if isinstance(level, int) and level >= obj.index.nlevels:
        raise ValueError("Param level out of bounds.")

    if not isinstance(labels, cudf.core.single_column_frame.SingleColumnFrame):
        labels = as_column(labels)

    if isinstance(obj._index, cudf.MultiIndex):
        if level is None:
            level = 0

        levels_index = obj.index.get_level_values(level)
        if errors == "raise" and not labels.isin(levels_index).all():
            raise KeyError("One or more values not found in axis")

        if isinstance(level, int):
            ilevel = level
        else:
            ilevel = obj._index.names.index(level)

        # 1. Merge Index df and data df along column axis:
        # | id | ._index df | data column(s) |
        idx_nlv = obj._index.nlevels
        working_df = obj._index.to_frame(index=False)
        working_df.columns = [i for i in range(idx_nlv)]
        for i, col in enumerate(obj._data):
            working_df[idx_nlv + i] = obj._data[col]
        # 2. Set `level` as common index:
        # | level | ._index df w/o level | data column(s) |
        working_df = working_df.set_index(level)

        # 3. Use "leftanti" join to drop
        # TODO: use internal API with "leftanti" and specify left and right
        # join keys to bypass logic check
        to_join = cudf.DataFrame(index=cudf.Index(labels, name=level))
        join_res = working_df.join(to_join, how="leftanti")

        # 4. Reconstruct original layout, and rename
        join_res.insert(
            ilevel, name=join_res._index.name, value=join_res._index
        )
        join_res = join_res.reset_index(drop=True)

        midx = cudf.MultiIndex.from_frame(
            join_res.iloc[:, 0:idx_nlv], names=obj._index.names
        )

        if isinstance(obj, cudf.Series):
            return obj.__class__._from_data(
                join_res.iloc[:, idx_nlv:]._data, index=midx, name=obj.name
            )
        else:
            return obj.__class__._from_data(
                join_res.iloc[:, idx_nlv:]._data,
                index=midx,
                columns=obj.columns,
            )

    else:
        if errors == "raise" and not labels.isin(obj.index).all():
            raise KeyError("One or more values not found in axis")

        key_df = cudf.DataFrame(index=labels)
        if isinstance(obj, cudf.Series):
            res = obj.to_frame(name="tmp").join(key_df, how="leftanti")["tmp"]
            res.name = obj.name
            return res
        else:
            return obj.join(key_df, how="leftanti")


def _apply_inverse_column(col: ColumnBase) -> ColumnBase:
    """Bitwise invert (~) for integral dtypes, logical NOT for bools."""
    if np.issubdtype(col.dtype, np.integer):
        return col.unary_operator("invert")
    elif np.issubdtype(col.dtype, np.bool_):
        return col.unary_operator("not")
    else:
        raise TypeError(
            f"Operation `~` not supported on {col.dtype.type.__name__}"
        )
