# Copyright (c) 2018-2021, NVIDIA CORPORATION.
from __future__ import annotations

import pickle
import warnings
from collections import abc as abc
from numbers import Number
from shutil import get_terminal_size
from typing import Any, Optional, Set
from uuid import uuid4

import cupy
import numpy as np
import pandas as pd
from nvtx import annotate
from pandas._config import get_option
from pandas.api.types import is_dict_like

import cudf
from cudf import _lib as libcudf
from cudf._lib.transform import bools_to_mask
from cudf.core.abc import Serializable
from cudf.core.column import (
    ColumnBase,
    DatetimeColumn,
    TimeDeltaColumn,
    arange,
    as_column,
    column,
    column_empty_like,
    full,
)
from cudf.core.column.categorical import (
    CategoricalAccessor as CategoricalAccessor,
)
from cudf.core.column.lists import ListMethods
from cudf.core.column.string import StringMethods
from cudf.core.column_accessor import ColumnAccessor
from cudf.core.frame import Frame
from cudf.core.groupby.groupby import SeriesGroupBy
from cudf.core.index import Index, RangeIndex, as_index
from cudf.core.indexing import _SeriesIlocIndexer, _SeriesLocIndexer
from cudf.core.window import Rolling
from cudf.utils import cudautils, docutils, ioutils, utils
from cudf.utils.docutils import copy_docstring
from cudf.utils.dtypes import (
    can_convert_to_column,
    is_decimal_dtype,
    is_list_dtype,
    is_list_like,
    is_mixed_with_object_dtype,
    is_scalar,
    is_string_dtype,
    min_scalar_type,
    numeric_normalize_types,
)
from cudf.utils.utils import (
    get_appropriate_dispatched_func,
    get_relevant_submodule,
)


class Series(Frame, Serializable):
    @property
    def _constructor(self):
        return Series

    @property
    def _constructor_sliced(self):
        raise NotImplementedError(
            "_constructor_sliced not supported for Series!"
        )

    @property
    def _constructor_expanddim(self):
        return cudf.DataFrame

    @classmethod
    def from_categorical(cls, categorical, codes=None):
        """Creates from a pandas.Categorical

        If ``codes`` is defined, use it instead of ``categorical.codes``
        """
        col = cudf.core.column.categorical.pandas_categorical_as_column(
            categorical, codes=codes
        )
        return Series(data=col)

    @classmethod
    def from_masked_array(cls, data, mask, null_count=None):
        """Create a Series with null-mask.
        This is equivalent to:

            Series(data).set_mask(mask, null_count=null_count)

        Parameters
        ----------
        data : 1D array-like
            The values.  Null values must not be skipped.  They can appear
            as garbage values.
        mask : 1D array-like
            The null-mask.  Valid values are marked as ``1``; otherwise ``0``.
            The mask bit given the data index ``idx`` is computed as::

                (mask[idx // 8] >> (idx % 8)) & 1
        null_count : int, optional
            The number of null values.
            If None, it is calculated automatically.
        """
        col = column.as_column(data).set_mask(mask)
        return cls(data=col)

    def __init__(
        self, data=None, index=None, dtype=None, name=None, nan_as_null=True,
    ):
        """
        One-dimensional GPU array (including time series).

        Labels need not be unique but must be a hashable type. The object
        supports both integer- and label-based indexing and provides a
        host of methods for performing operations involving the index.
        Statistical methods from ndarray have been overridden to
        automatically exclude missing data (currently represented
        as null/NaN).

        Operations between Series (`+`, `-`, `/`, `*`, `**`) align
        values based on their associated index values-– they need
        not be the same length. The result index will be the
        sorted union of the two indexes.

        ``Series`` objects are used as columns of ``DataFrame``.

        Parameters
        ----------
        data : array-like, Iterable, dict, or scalar value
            Contains data stored in Series.

        index : array-like or Index (1d)
            Values must be hashable and have the same length
            as data. Non-unique index values are allowed. Will
            default to RangeIndex (0, 1, 2, …, n) if not provided.
            If both a dict and index sequence are used, the index will
            override the keys found in the dict.

        dtype : str, numpy.dtype, or ExtensionDtype, optional
            Data type for the output Series. If not specified,
            this will be inferred from data.

        name : str, optional
            The name to give to the Series.

        nan_as_null : bool, Default True
            If ``None``/``True``, converts ``np.nan`` values to
            ``null`` values.
            If ``False``, leaves ``np.nan`` values as is.
        """
        if isinstance(data, pd.Series):
            if name is None:
                name = data.name
            if isinstance(data.index, pd.MultiIndex):
                index = cudf.from_pandas(data.index)
            else:
                index = as_index(data.index)
        elif isinstance(data, pd.Index):
            name = data.name
            data = data.values
        elif isinstance(data, Index):
            name = data.name
            data = data._values
            if dtype is not None:
                data = data.astype(dtype)
        elif isinstance(data, ColumnAccessor):
            name, data = data.names[0], data.columns[0]

        if isinstance(data, Series):
            index = data._index if index is None else index
            if name is None:
                name = data.name
            data = data._column
            if dtype is not None:
                data = data.astype(dtype)

        if isinstance(data, dict):
            index = data.keys()
            data = column.as_column(
                data.values(), nan_as_null=nan_as_null, dtype=dtype
            )

        if data is None:
            if index is not None:
                data = column.column_empty(
                    row_count=len(index), dtype=None, masked=True
                )
            else:
                data = {}

        if not isinstance(data, column.ColumnBase):
            data = column.as_column(data, nan_as_null=nan_as_null, dtype=dtype)
        else:
            if dtype is not None:
                data = data.astype(dtype)

        if index is not None and not isinstance(index, Index):
            index = as_index(index)

        assert isinstance(data, column.ColumnBase)

        super().__init__({name: data})
        self._index = RangeIndex(len(data)) if index is None else index

    @classmethod
    def _from_table(cls, table, index=None):
        name = next(iter(table._data.keys()))
        data = next(iter(table._data.values()))
        if index is None:
            if table._index is not None:
                index = Index._from_table(table._index)
        return cls(data=data, index=index, name=name)

    @classmethod
    def _from_data(
        cls,
        data: ColumnAccessor,
        index: Optional[Index] = None,
        name: Any = None,
    ) -> Series:
        """
        Construct the Series from a ColumnAccessor
        """
        out = cls.__new__(cls)
        out._data = data
        out._index = index if index is not None else RangeIndex(data.nrows)
        if name is not None:
            out.name = name
        return out

    @property
    def _column(self):
        return self._data[self.name]

    @_column.setter
    def _column(self, value):
        self._data[self.name] = value

    def __contains__(self, item):
        return item in self._index

    @classmethod
    def from_pandas(cls, s, nan_as_null=None):
        """
        Convert from a Pandas Series.

        Parameters
        ----------
        s : Pandas Series object
            A Pandas Series object which has to be converted
            to cuDF Series.
        nan_as_null : bool, Default None
            If ``None``/``True``, converts ``np.nan`` values to
            ``null`` values.
            If ``False``, leaves ``np.nan`` values as is.

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

        Examples
        --------
        >>> import cudf
        >>> import pandas as pd
        >>> import numpy as np
        >>> data = [10, 20, 30, np.nan]
        >>> pds = pd.Series(data)
        >>> cudf.Series.from_pandas(pds)
        0    10.0
        1    20.0
        2    30.0
        3    <NA>
        dtype: float64
        >>> cudf.Series.from_pandas(pds, nan_as_null=False)
        0    10.0
        1    20.0
        2    30.0
        3     NaN
        dtype: float64
        """
        return cls(s, nan_as_null=nan_as_null)

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

        Only the values in the Series will be returned.

        Returns
        -------
        out : cupy.ndarray
            The values of the Series.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, -10, 100, 20])
        >>> ser.values
        array([  1, -10, 100,  20])
        >>> type(ser.values)
        <class 'cupy.core.core.ndarray'>
        """
        return self._column.values

    @property
    def values_host(self):
        """
        Return a numpy representation of the Series.

        Only the values in the Series will be returned.

        Returns
        -------
        out : numpy.ndarray
            The values of the Series.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, -10, 100, 20])
        >>> ser.values_host
        array([  1, -10, 100,  20])
        >>> type(ser.values_host)
        <class 'numpy.ndarray'>
        """
        return self._column.values_host

    def serialize(self):
        header = {}
        frames = []
        header["index"], index_frames = self._index.serialize()
        header["name"] = pickle.dumps(self.name)
        frames.extend(index_frames)
        header["index_frame_count"] = len(index_frames)
        header["column"], column_frames = self._column.serialize()
        header["type-serialized"] = pickle.dumps(type(self))
        frames.extend(column_frames)
        header["column_frame_count"] = len(column_frames)

        return header, frames

    @property
    def shape(self):
        """Returns a tuple representing the dimensionality of the Series.
        """
        return (len(self),)

    @property
    def dt(self):
        """
        Accessor object for datetimelike properties of the Series values.

        Examples
        --------
        >>> s.dt.hour
        >>> s.dt.second
        >>> s.dt.day

        Returns
        -------
            A Series indexed like the original Series.

        Raises
        ------
            TypeError if the Series does not contain datetimelike values.
        """
        if isinstance(self._column, DatetimeColumn):
            return DatetimeProperties(self)
        elif isinstance(self._column, TimeDeltaColumn):
            return TimedeltaProperties(self)
        else:
            raise AttributeError(
                "Can only use .dt accessor with datetimelike values"
            )

    @property
    def ndim(self):
        """Dimension of the data. Series ndim is always 1.
        """
        return 1

    @property
    def name(self):
        """Returns name of the Series.
        """
        return self._data.names[0]

    @name.setter
    def name(self, value):
        col = self._data.pop(self.name)
        self._data[value] = col

    @classmethod
    def deserialize(cls, header, frames):
        index_nframes = header["index_frame_count"]
        idx_typ = pickle.loads(header["index"]["type-serialized"])
        index = idx_typ.deserialize(header["index"], frames[:index_nframes])
        name = pickle.loads(header["name"])

        frames = frames[index_nframes:]

        column_nframes = header["column_frame_count"]
        col_typ = pickle.loads(header["column"]["type-serialized"])
        column = col_typ.deserialize(header["column"], frames[:column_nframes])

        return Series(column, index=index, name=name)

    def _copy_construct_defaults(self):
        return dict(data=self._column, index=self._index, name=self.name)

    def _copy_construct(self, **kwargs):
        """Shallow copy this object by replacing certain ctor args.
        """
        params = self._copy_construct_defaults()
        cls = type(self)
        params.update(kwargs)
        return cls(**params)

    def _get_columns_by_label(self, labels, downcast=False):
        """Return the column specified by `labels`

        For cudf.Series, either the column, or an empty series is returned.
        Parameter `downcast` does not have effects.
        """
        new_data = super()._get_columns_by_label(labels, downcast)

        return (
            self._constructor(data=new_data, index=self.index)
            if len(new_data) > 0
            else self._constructor(dtype=self.dtype, name=self.name)
        )

    @classmethod
    def from_arrow(cls, array):
        """
        Convert from PyArrow Array/ChunkedArray to Series.

        Parameters
        ----------
        array : PyArrow Array/ChunkedArray
            PyArrow Object which has to be converted to cudf Series.

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

        Returns
        -------
        cudf Series

        Examples
        --------
        >>> import cudf
        >>> import pyarrow as pa
        >>> cudf.Series.from_arrow(pa.array(["a", "b", None]))
        0       a
        1       b
        2    <NA>
        dtype: object
        """

        return cls(cudf.core.column.ColumnBase.from_arrow(array))

    def to_arrow(self):
        """
        Convert Series to a PyArrow Array.

        Returns
        -------
        PyArrow Array

        Examples
        --------
        >>> import cudf
        >>> sr = cudf.Series(["a", "b", None])
        >>> sr.to_arrow()
        <pyarrow.lib.StringArray object at 0x7f796b0e7600>
        [
          "a",
          "b",
          null
        ]
        """
        return self._column.to_arrow()

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

    def __deepcopy__(self, memo=None):
        if memo is None:
            memo = {}
        return self.copy()

    def append(self, to_append, ignore_index=False, verify_integrity=False):
        """Append values from another ``Series`` or array-like object.
        If ``ignore_index=True``, the index is reset.

        Parameters
        ----------
        to_append : Series or list/tuple of Series
            Series to append with self.
        ignore_index : boolean, default False.
            If True, do not use the index.
        verify_integrity : bool, default False
            This Parameter is currently not supported.

        Returns
        -------
        Series
            A new concatenated series

        See Also
        --------
        cudf.core.reshape.concat : General function to concatenate DataFrame or
            Series objects.

        Examples
        --------
        >>> import cudf
        >>> s1 = cudf.Series([1, 2, 3])
        >>> s2 = cudf.Series([4, 5, 6])
        >>> s1
        0    1
        1    2
        2    3
        dtype: int64
        >>> s2
        0    4
        1    5
        2    6
        dtype: int64
        >>> s1.append(s2)
        0    1
        1    2
        2    3
        0    4
        1    5
        2    6
        dtype: int64

        >>> s3 = cudf.Series([4, 5, 6], index=[3, 4, 5])
        >>> s3
        3    4
        4    5
        5    6
        dtype: int64
        >>> s1.append(s3)
        0    1
        1    2
        2    3
        3    4
        4    5
        5    6
        dtype: int64

        With `ignore_index` set to True:

        >>> s1.append(s2, ignore_index=True)
        0    1
        1    2
        2    3
        3    4
        4    5
        5    6
        dtype: int64
        """
        if verify_integrity not in (None, False):
            raise NotImplementedError(
                "verify_integrity parameter is not supported yet."
            )

        if is_list_like(to_append):
            to_concat = [self]
            to_concat.extend(to_append)
        else:
            to_concat = [self, to_append]

        return cudf.concat(to_concat, ignore_index=ignore_index)

    def reindex(self, index=None, copy=True):
        """Return a Series that conforms to a new index

        Parameters
        ----------
        index : Index, Series-convertible, default None
        copy : boolean, default True

        Returns
        -------
        A new Series that conforms to the supplied index
        """
        name = self.name or 0
        idx = self._index if index is None else index
        return self.to_frame(name).reindex(idx, copy=copy)[name]

    def reset_index(self, drop=False, inplace=False):
        """ Reset index to RangeIndex """
        if not drop:
            if inplace is True:
                raise TypeError(
                    "Cannot reset_index inplace on a Series "
                    "to create a DataFrame"
                )
            return self.to_frame().reset_index(drop=drop)
        else:
            if inplace is True:
                self._index = RangeIndex(len(self))
            else:
                return self._copy_construct(index=RangeIndex(len(self)))

    def set_index(self, index):
        """Returns a new Series with a different index.

        Parameters
        ----------
        index : Index, Series-convertible
            the new index or values for the new index
        """
        index = index if isinstance(index, Index) else as_index(index)
        return self._copy_construct(index=index)

    def as_index(self):
        """Returns a new Series with a RangeIndex.

        Examples
        ----------
        >>> s = cudf.Series([1,2,3], index=['a','b','c'])
        >>> s
        a    1
        b    2
        c    3
        dtype: int64
        >>> s.as_index()
        0    1
        1    2
        2    3
        dtype: int64
        """
        return self.set_index(RangeIndex(len(self)))

    def to_frame(self, name=None):
        """Convert Series into a DataFrame

        Parameters
        ----------
        name : str, default None
            Name to be used for the column

        Returns
        -------
        DataFrame
            cudf DataFrame
        """

        if name is not None:
            col = name
        elif self.name is None:
            col = 0
        else:
            col = self.name

        return cudf.DataFrame({col: self._column}, index=self.index)

    def set_mask(self, mask, null_count=None):
        """Create new Series by setting a mask array.

        This will override the existing mask.  The returned Series will
        reference the same data buffer as this Series.

        Parameters
        ----------
        mask : 1D array-like
            The null-mask.  Valid values are marked as ``1``; otherwise ``0``.
            The mask bit given the data index ``idx`` is computed as::

                (mask[idx // 8] >> (idx % 8)) & 1
        null_count : int, optional
            The number of null values.
            If None, it is calculated automatically.
        """
        col = self._column.set_mask(mask)
        return self._copy_construct(data=col)

    def __sizeof__(self):
        return self._column.__sizeof__() + self._index.__sizeof__()

    def memory_usage(self, index=True, deep=False):
        """
        Return the memory usage of the Series.

        The memory usage can optionally include the contribution of
        the index and of elements of `object` dtype.

        Parameters
        ----------
        index : bool, default True
            Specifies whether to include the memory usage of the Series index.
        deep : bool, default False
            If True, introspect the data deeply by interrogating
            `object` dtypes for system-level memory consumption, and include
            it in the returned value.

        Returns
        -------
        int
            Bytes of memory consumed.

        See Also
        --------
        cudf.core.dataframe.DataFrame.memory_usage : Bytes consumed by
            a DataFrame.

        Examples
        --------
        >>> s = cudf.Series(range(3), index=['a','b','c'])
        >>> s.memory_usage()
        48

        Not including the index gives the size of the rest of the data, which
        is necessarily smaller:

        >>> s.memory_usage(index=False)
        24
        """
        n = self._column._memory_usage(deep=deep)
        if index:
            n += self._index.memory_usage(deep=deep)
        return n

    def __len__(self):
        """Returns the size of the ``Series`` including null values.
        """
        return len(self._column)

    def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
        if method == "__call__":
            return get_appropriate_dispatched_func(
                cudf, cudf.Series, cupy, ufunc, inputs, kwargs
            )
        else:
            return NotImplemented

    def __array_function__(self, func, types, args, kwargs):
        handled_types = [cudf.Series]
        for t in types:
            if t not in handled_types:
                return NotImplemented

        cudf_submodule = get_relevant_submodule(func, cudf)
        cudf_ser_submodule = get_relevant_submodule(func, cudf.Series)
        cupy_submodule = get_relevant_submodule(func, cupy)

        return get_appropriate_dispatched_func(
            cudf_submodule,
            cudf_ser_submodule,
            cupy_submodule,
            func,
            args,
            kwargs,
        )

    def map(self, arg, na_action=None) -> "Series":
        """
        Map values of Series according to input correspondence.

        Used for substituting each value in a Series with another value,
        that may be derived from a function, a ``dict`` or
        a :class:`Series`.

        Parameters
        ----------
        arg : function, collections.abc.Mapping subclass or Series
            Mapping correspondence.
        na_action : {None, 'ignore'}, default None
            If 'ignore', propagate NaN values, without passing them to the
            mapping correspondence.

        Returns
        -------
        Series
            Same index as caller.

        Examples
        --------
        >>> s = cudf.Series(['cat', 'dog', np.nan, 'rabbit'])
        >>> s
        0      cat
        1      dog
        2     <NA>
        3   rabbit
        dtype: object

        ``map`` accepts a ``dict`` or a ``Series``. Values that are not found
        in the ``dict`` are converted to ``NaN``, default values in dicts are
        currently not supported.:

        >>> s.map({'cat': 'kitten', 'dog': 'puppy'})
        0   kitten
        1    puppy
        2     <NA>
        3     <NA>
        dtype: object

        It also accepts numeric functions:

        >>> s = cudf.Series([1, 2, 3, 4, np.nan])
        >>> s.map(lambda x: x ** 2)
        0       1
        1       4
        2       9
        3       16
        4     <NA>
        dtype: int64

        Notes
        -----
        Please note map currently only supports fixed-width numeric
        type functions.
        """
        if isinstance(arg, dict):
            if hasattr(arg, "__missing__"):
                raise NotImplementedError(
                    "default values in dicts are currently not supported."
                )
            lhs = cudf.DataFrame({"x": self, "orig_order": arange(len(self))})
            rhs = cudf.DataFrame(
                {
                    "x": arg.keys(),
                    "s": arg.values(),
                    "bool": full(len(arg), True, dtype=self.dtype),
                }
            )
            res = lhs.merge(rhs, on="x", how="left").sort_values(
                by="orig_order"
            )
            result = res["s"]
            result.name = self.name
            result.index = self.index
        elif isinstance(arg, cudf.Series):
            if not arg.index.is_unique:
                raise ValueError(
                    "Reindexing only valid with"
                    " uniquely valued Index objects"
                )
            lhs = cudf.DataFrame({"x": self, "orig_order": arange(len(self))})
            rhs = cudf.DataFrame(
                {
                    "x": arg.keys(),
                    "s": arg,
                    "bool": full(len(arg), True, dtype=self.dtype),
                }
            )
            res = lhs.merge(rhs, on="x", how="left").sort_values(
                by="orig_order"
            )
            result = res["s"]
            result.name = self.name
            result.index = self.index
        else:
            result = self.applymap(arg)
        return result

    def __getitem__(self, arg):
        if isinstance(arg, slice):
            return self.iloc[arg]
        else:
            return self.loc[arg]

    def __iter__(self):
        cudf.utils.utils.raise_iteration_error(obj=self)

    iteritems = __iter__

    items = __iter__

    def to_dict(self, into=dict):
        raise TypeError(
            "cuDF does not support conversion to host memory "
            "via `to_dict()` method. Consider using "
            "`.to_pandas().to_dict()` to construct a Python dictionary."
        )

    def __setitem__(self, key, value):
        if isinstance(key, slice):
            self.iloc[key] = value
        else:
            self.loc[key] = value

    def take(self, indices, keep_index=True):
        """Return Series by taking values from the corresponding *indices*.
        """
        if keep_index is True or is_scalar(indices):
            return self.iloc[indices]
        else:
            col_inds = as_column(indices)
            data = self._column.take(col_inds, keep_index=False)
            return self._copy_construct(data=data)

    def __bool__(self):
        """Always raise TypeError when converting a Series
        into a boolean.
        """
        raise TypeError(f"can't compute boolean for {type(self)}")

    def values_to_string(self, nrows=None):
        """Returns a list of string for each element.
        """
        values = self[:nrows]
        if self.dtype == np.dtype("object"):
            out = [str(v) for v in values]
        else:
            out = ["" if v is None else str(v) for v in values]
        return out

    def tolist(self):

        raise TypeError(
            "cuDF does not support conversion to host memory "
            "via `tolist()` method. Consider using "
            "`.to_arrow().to_pylist()` to construct a Python list."
        )

    to_list = tolist

    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
        -------
        same type as caller
            The first `n` rows of the caller object.

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

        Examples
        --------
        >>> 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
        """
        return self.iloc[:n]

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

        Examples
        --------
        >>> 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:]

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

        Uses Pandas formatting internals to produce output identical to Pandas.
        Use the Pandas formatting settings directly in Pandas to control cuDF
        output.
        """
        return self.__repr__()

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

    def __repr__(self):
        _, height = get_terminal_size()
        max_rows = (
            height
            if get_option("display.max_rows") == 0
            else get_option("display.max_rows")
        )

        if len(self) > max_rows and max_rows != 0:
            top = self.head(int(max_rows / 2 + 1))
            bottom = self.tail(int(max_rows / 2 + 1))

            preprocess = cudf.concat([top, bottom])
        else:
            preprocess = self.copy()

        preprocess.index = preprocess.index._clean_nulls_from_index()
        if (
            preprocess.nullable
            and not isinstance(
                preprocess._column, cudf.core.column.CategoricalColumn
            )
            and not is_list_dtype(preprocess.dtype)
            and not is_decimal_dtype(preprocess.dtype)
        ) or isinstance(
            preprocess._column, cudf.core.column.timedelta.TimeDeltaColumn
        ):
            output = (
                preprocess.astype("O")
                .fillna(cudf._NA_REP)
                .to_pandas()
                .__repr__()
            )
        elif isinstance(
            preprocess._column, cudf.core.column.CategoricalColumn
        ):
            min_rows = (
                height
                if get_option("display.max_rows") == 0
                else get_option("display.min_rows")
            )
            show_dimensions = get_option("display.show_dimensions")
            output = preprocess.to_pandas().to_string(
                name=self.name,
                dtype=self.dtype,
                min_rows=min_rows,
                max_rows=max_rows,
                length=show_dimensions,
                na_rep=cudf._NA_REP,
            )
        else:
            output = preprocess.to_pandas().__repr__()

        lines = output.split("\n")

        if isinstance(preprocess._column, cudf.core.column.CategoricalColumn):
            category_memory = lines[-1]
            lines = lines[:-1]
        if len(lines) > 1:
            if lines[-1].startswith("Name: "):
                lines = lines[:-1]
                lines.append("Name: %s" % str(self.name))
                if len(self) > len(preprocess):
                    lines[-1] = lines[-1] + ", Length: %d" % len(self)
                lines[-1] = lines[-1] + ", "
            elif lines[-1].startswith("Length: "):
                lines = lines[:-1]
                lines.append("Length: %d" % len(self))
                lines[-1] = lines[-1] + ", "
            else:
                lines = lines[:-1]
                lines[-1] = lines[-1] + "\n"
            lines[-1] = lines[-1] + "dtype: %s" % self.dtype
        else:
            lines = output.split(",")
            return lines[0] + ", dtype: %s)" % self.dtype
        if isinstance(preprocess._column, cudf.core.column.CategoricalColumn):
            lines.append(category_memory)
        return "\n".join(lines)

    @annotate("BINARY_OP", color="orange", domain="cudf_python")
    def _binaryop(self, other, fn, fill_value=None, reflect=False):
        """
        Internal util to call a binary operator *fn* on operands *self*
        and *other*.  Return the output Series.  The output dtype is
        determined by the input operands.

        If ``reflect`` is ``True``, swap the order of the operands.
        """
        if isinstance(other, cudf.DataFrame):
            # TODO: fn is not the same as arg expected by _apply_op
            # e.g. for fn = 'and', _apply_op equivalent is '__and__'
            return other._apply_op(self, fn)

        result_name = utils.get_result_name(self, other)
        if isinstance(other, Series):
            if fn in cudf.utils.utils._EQUALITY_OPS:
                if not self.index.equals(other.index):
                    raise ValueError(
                        "Can only compare identically-labeled "
                        "Series objects"
                    )
            lhs, rhs = _align_indices([self, other], allow_non_unique=True)
        else:
            lhs, rhs = self, other
        rhs = self._normalize_binop_value(rhs)

        if fn == "truediv":
            if str(lhs.dtype) in truediv_int_dtype_corrections:
                truediv_type = truediv_int_dtype_corrections[str(lhs.dtype)]
                lhs = lhs.astype(truediv_type)

        if fill_value is not None:
            if is_scalar(rhs):
                lhs = lhs.fillna(fill_value)
            else:
                if lhs.nullable and rhs.nullable:
                    lmask = Series(data=lhs.nullmask)
                    rmask = Series(data=rhs.nullmask)
                    mask = (lmask | rmask).data
                    lhs = lhs.fillna(fill_value)
                    rhs = rhs.fillna(fill_value)
                    result = lhs._binaryop(rhs, fn=fn, reflect=reflect)
                    data = column.build_column(
                        data=result.data, dtype=result.dtype, mask=mask
                    )
                    return lhs._copy_construct(data=data)
                elif lhs.nullable:
                    lhs = lhs.fillna(fill_value)
                elif rhs.nullable:
                    rhs = rhs.fillna(fill_value)

        outcol = lhs._column.binary_operator(fn, rhs, reflect=reflect)
        result = lhs._copy_construct(data=outcol, name=result_name)
        return result

    def add(self, other, fill_value=None, axis=0):
        """Addition of series and other, element-wise
        (binary operator add).

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "add", fill_value)

    def __add__(self, other):
        return self._binaryop(other, "add")

    def radd(self, other, fill_value=None, axis=0):
        """Addition of series and other, element-wise
        (binary operator radd).

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(
            other, "add", fill_value=fill_value, reflect=True
        )

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

    def sub(self, other, fill_value=None, axis=0):
        """Subtraction of series and other, element-wise
        (binary operator sub).

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "sub", fill_value)

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

    def rsub(self, other, fill_value=None, axis=0):
        """Subtraction of series and other, element-wise
        (binary operator rsub).

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "sub", fill_value, reflect=True)

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

    def mul(self, other, fill_value=None, axis=0):
        """Multiplication of series and other, element-wise
        (binary operator mul).

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "mul", fill_value=fill_value)

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

    def rmul(self, other, fill_value=None, axis=0):
        """Multiplication of series and other, element-wise
        (binary operator rmul).

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "mul", fill_value, True)

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

    def mod(self, other, fill_value=None, axis=0):
        """Modulo of series and other, element-wise
        (binary operator mod).

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "mod", fill_value)

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

    def rmod(self, other, fill_value=None, axis=0):
        """Modulo of series and other, element-wise
        (binary operator rmod).

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "mod", fill_value, True)

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

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

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "pow", fill_value)

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

    def rpow(self, other, fill_value=None, axis=0):
        """Exponential power of series and other, element-wise
        (binary operator rpow).

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "pow", fill_value, True)

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

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

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "floordiv", fill_value)

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

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

        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
        -------
        Series
            Result of the arithmetic operation.

        Examples
        --------
        >>> 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 axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "floordiv", fill_value, True)

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

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

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "truediv", fill_value)

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

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

        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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "truediv", fill_value, True)

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

    __div__ = __truediv__

    def _bitwise_binop(self, other, op):
        if (
            np.issubdtype(self.dtype, np.bool_)
            or np.issubdtype(self.dtype, np.integer)
        ) and (
            np.issubdtype(other.dtype, np.bool_)
            or np.issubdtype(other.dtype, np.integer)
        ):
            # TODO: This doesn't work on Series (op) DataFrame
            # because dataframe doesn't have dtype
            ser = self._binaryop(other, op)
            if np.issubdtype(self.dtype, np.bool_) or np.issubdtype(
                other.dtype, np.bool_
            ):
                ser = ser.astype(np.bool_)
            return ser
        else:
            raise TypeError(
                f"Operation 'bitwise {op}' not supported between "
                f"{self.dtype.type.__name__} and {other.dtype.type.__name__}"
            )

    def __and__(self, other):
        """Performs vectorized bitwise and (&) on corresponding elements of two
        series.
        """
        return self._bitwise_binop(other, "and")

    def __or__(self, other):
        """Performs vectorized bitwise or (|) on corresponding elements of two
        series.
        """
        return self._bitwise_binop(other, "or")

    def __xor__(self, other):
        """Performs vectorized bitwise xor (^) on corresponding elements of two
        series.
        """
        return self._bitwise_binop(other, "xor")

    def logical_and(self, other):
        ser = self._binaryop(other, "l_and")
        return ser.astype(np.bool_)

    def remainder(self, other):
        ser = self._binaryop(other, "mod")
        return ser

    def logical_or(self, other):
        ser = self._binaryop(other, "l_or")
        return ser.astype(np.bool_)

    def logical_not(self):
        return self._unaryop("not")

    def _normalize_binop_value(self, other):
        """Returns a *column* (not a Series) or scalar for performing
        binary operations with self._column.
        """
        if isinstance(other, ColumnBase):
            return other
        if isinstance(other, Series):
            return other._column
        elif isinstance(other, Index):
            return Series(other)._column
        elif other is cudf.NA:
            return cudf.Scalar(other, dtype=self.dtype)
        else:
            return self._column.normalize_binop_value(other)

    def eq(self, other, fill_value=None, axis=0):
        """Equal to of series and other, 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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "eq", fill_value)

    def __eq__(self, other):
        return self._binaryop(other, "eq")

    def ne(self, other, fill_value=None, axis=0):
        """Not equal to of series and other, 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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "ne", fill_value)

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

    def lt(self, other, fill_value=None, axis=0):
        """Less than of series and other, 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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "lt", fill_value)

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

    def le(self, other, fill_value=None, axis=0):
        """Less than or equal to of series and other, 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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "le", fill_value)

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

    def gt(self, other, fill_value=None, axis=0):
        """Greater than of series and other, 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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "gt", fill_value)

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

    def ge(self, other, fill_value=None, axis=0):
        """Greater than or equal to of series and other, 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
        """
        if axis != 0:
            raise NotImplementedError("Only axis=0 supported at this time.")
        return self._binaryop(other, "ge", fill_value)

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

    def __invert__(self):
        """Bitwise invert (~) for each element.
        Logical NOT if dtype is bool

        Returns a new Series.
        """
        if np.issubdtype(self.dtype, np.integer):
            return self._unaryop("invert")
        elif np.issubdtype(self.dtype, np.bool_):
            return self._unaryop("not")
        else:
            raise TypeError(
                f"Operation `~` not supported on {self.dtype.type.__name__}"
            )

    def __neg__(self):
        """Negated value (-) for each element

        Returns a new Series.
        """
        return self.__mul__(-1)

    @copy_docstring(CategoricalAccessor.__init__)  # type: ignore
    @property
    def cat(self):
        return CategoricalAccessor(column=self._column, parent=self)

    @copy_docstring(StringMethods.__init__)  # type: ignore
    @property
    def str(self):
        return StringMethods(column=self._column, parent=self)

    @copy_docstring(ListMethods.__init__)  # type: ignore
    @property
    def list(self):
        return ListMethods(column=self._column, parent=self)

    @property
    def dtype(self):
        """dtype of the Series"""
        return self._column.dtype

    @classmethod
    def _concat(cls, objs, axis=0, index=True):
        # Concatenate index if not provided
        if index is True:
            if isinstance(objs[0].index, cudf.MultiIndex):
                index = cudf.MultiIndex._concat([o.index for o in objs])
            else:
                index = Index._concat([o.index for o in objs])

        names = {obj.name for obj in objs}
        if len(names) == 1:
            [name] = names
        else:
            name = None

        if len(objs) > 1:
            dtype_mismatch = False
            for obj in objs[1:]:
                if (
                    obj.null_count == len(obj)
                    or len(obj) == 0
                    or isinstance(
                        obj._column, cudf.core.column.CategoricalColumn
                    )
                    or isinstance(
                        objs[0]._column, cudf.core.column.CategoricalColumn
                    )
                ):
                    continue

                if (
                    not dtype_mismatch
                    and (
                        not isinstance(
                            objs[0]._column, cudf.core.column.CategoricalColumn
                        )
                        and not isinstance(
                            obj._column, cudf.core.column.CategoricalColumn
                        )
                    )
                    and objs[0].dtype != obj.dtype
                ):
                    dtype_mismatch = True

                if is_mixed_with_object_dtype(objs[0], obj):
                    raise TypeError(
                        "cudf does not support mixed types, please type-cast "
                        "both series to same dtypes."
                    )

            if dtype_mismatch:
                objs = numeric_normalize_types(*objs)

        col = ColumnBase._concat([o._column for o in objs])
        return cls(data=col, index=index, name=name)

    @property
    def valid_count(self):
        """Number of non-null values"""
        return self._column.valid_count

    @property
    def null_count(self):
        """Number of null values"""
        return self._column.null_count

    @property
    def nullable(self):
        """A boolean indicating whether a null-mask is needed"""
        return self._column.nullable

    @property
    def has_nulls(self):
        """
        Indicator whether Series contains null values.

        Returns
        -------
        out : bool
            If Series has atleast one null value, return True, if not
            return False.
        """
        return self._column.has_nulls

    def dropna(self, axis=0, inplace=False, how=None):
        """
        Return a Series with null values removed.

        Parameters
        ----------
        axis : {0 or ‘index’}, default 0
            There is only one axis to drop values from.
        inplace : bool, default False
            If True, do operation inplace and return None.
        how : str, optional
            Not in use. Kept for compatibility.

        Returns
        -------
        Series
            Series with null entries dropped from it.

        See Also
        --------
        Series.isna : Indicate null values.

        Series.notna : Indicate non-null values.

        Series.fillna : Replace null values.

        cudf.core.dataframe.DataFrame.dropna : Drop rows or columns which
            contain null values.

        cudf.core.index.Index.dropna : Drop null indices.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, 2, None])
        >>> ser
        0       1
        1       2
        2    null
        dtype: int64

        Drop null values from a Series.

        >>> ser.dropna()
        0    1
        1    2
        dtype: int64

        Keep the Series with valid entries in the same variable.

        >>> ser.dropna(inplace=True)
        >>> ser
        0    1
        1    2
        dtype: int64

        Empty strings are not considered null values.
        `None` is considered a null value.

        >>> ser = cudf.Series(['', None, 'abc'])
        >>> ser
        0
        1    <NA>
        2     abc
        dtype: object
        >>> ser.dropna()
        0
        2    abc
        dtype: object
        """
        if axis not in (0, "index"):
            raise ValueError(
                "Series.dropna supports only one axis to drop values from"
            )

        result = super().dropna(axis=axis)

        return self._mimic_inplace(result, inplace=inplace)

    def drop_duplicates(self, keep="first", inplace=False, ignore_index=False):
        """
        Return Series with duplicate values removed
        """
        result = super().drop_duplicates(keep=keep, ignore_index=ignore_index)

        return self._mimic_inplace(result, inplace=inplace)

    def fill(self, fill_value, begin=0, end=-1, inplace=False):
        return self._fill([fill_value], begin, end, inplace)

    def fillna(
        self, value=None, method=None, axis=None, inplace=False, limit=None
    ):
        if isinstance(value, pd.Series):
            value = Series.from_pandas(value)

        if not (is_scalar(value) or isinstance(value, (abc.Mapping, Series))):
            raise TypeError(
                f'"value" parameter must be a scalar, dict '
                f"or Series, but you passed a "
                f'"{type(value).__name__}"'
            )

        if isinstance(value, (abc.Mapping, Series)):
            value = Series(value)
            if not self.index.equals(value.index):
                value = value.reindex(self.index)
            value = value._column

        return super().fillna(
            value=value, method=method, axis=axis, inplace=inplace, limit=limit
        )

    def to_array(self, fillna=None):
        """Get a dense numpy array for the data.

        Parameters
        ----------
        fillna : str or None
            Defaults to None, which will skip null values.
            If it equals "pandas", null values are filled with NaNs.
            Non integral dtype is promoted to np.float64.

        Notes
        -----

        If ``fillna`` is ``None``, null values are skipped.  Therefore, the
        output size could be smaller.
        """
        return self._column.to_array(fillna=fillna)

    def nans_to_nulls(self):
        """
        Convert nans (if any) to nulls
        """
        result_col = self._column.nans_to_nulls()
        return self._copy_construct(data=result_col)

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

        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
        -------
        scalar

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

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.all()
        True
        """

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if bool_only not in (None, True):
            raise NotImplementedError(
                "bool_only parameter is not implemented yet"
            )

        if skipna:
            result_series = self.nans_to_nulls()
            if len(result_series) == result_series.null_count:
                return True
        else:
            result_series = self
        return result_series._column.all()

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

        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
        -------
        scalar

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

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.any()
        True
        """

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if bool_only not in (None, True):
            raise NotImplementedError(
                "bool_only parameter is not implemented yet"
            )

        if self.empty:
            return False

        if skipna:
            result_series = self.nans_to_nulls()
            if len(result_series) == result_series.null_count:
                return False

        else:
            result_series = self

        return result_series._column.any()

    def to_gpu_array(self, fillna=None):
        """Get a dense numba device array for the data.

        Parameters
        ----------
        fillna : str or None
            See *fillna* in ``.to_array``.

        Notes
        -----

        if ``fillna`` is ``None``, null values are skipped.  Therefore, the
        output size could be smaller.
        """
        return self._column.to_gpu_array(fillna=fillna)

    def to_pandas(self, index=True, nullable=False, **kwargs):
        """
        Convert to a Pandas Series.

        Parameters
        ----------
        index : Boolean, Default True
            If ``index`` is ``True``, converts the index of cudf.Series
            and sets it to the pandas.Series. If ``index`` is ``False``,
            no index conversion is performed and pandas.Series will assign
            a default index.
        nullable : Boolean, Default False
            If ``nullable`` is ``True``, the resulting series will be
            having a corresponding nullable Pandas dtype. If ``nullable``
            is ``False``, the resulting series will either convert null
            values to ``np.nan`` or ``None`` depending on the dtype.

        Returns
        -------
        out : Pandas Series

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([-3, 2, 0])
        >>> pds = ser.to_pandas()
        >>> pds
        0   -3
        1    2
        2    0
        dtype: int64
        >>> type(pds)
        <class 'pandas.core.series.Series'>

        ``nullable`` parameter can be used to control
        whether dtype can be Pandas Nullable or not:

        >>> ser = cudf.Series([10, 20, None, 30])
        >>> ser
        0      10
        1      20
        2    <NA>
        3      30
        dtype: int64
        >>> ser.to_pandas(nullable=True)
        0      10
        1      20
        2    <NA>
        3      30
        dtype: Int64
        >>> ser.to_pandas(nullable=False)
        0    10.0
        1    20.0
        2     NaN
        3    30.0
        dtype: float64
        """

        if index is True:
            index = self.index.to_pandas()
        s = self._column.to_pandas(index=index, nullable=nullable)
        s.name = self.name
        return s

    @property
    def data(self):
        """The gpu buffer for the data
        """
        return self._column.data

    @property
    def index(self):
        """The index object
        """
        return self._index

    @index.setter
    def index(self, _index):
        self._index = as_index(_index)

    @property
    def loc(self):
        """
        Select values by label.

        See also
        --------
        cudf.core.dataframe.DataFrame.loc
        """
        return _SeriesLocIndexer(self)

    @property
    def iloc(self):
        """
        Select values by position.

        See also
        --------
        cudf.core.dataframe.DataFrame.iloc
        """
        return _SeriesIlocIndexer(self)

    @property
    def nullmask(self):
        """The gpu buffer for the null-mask
        """
        return cudf.Series(self._column.nullmask)

    def as_mask(self):
        """Convert booleans to bitmask

        Returns
        -------
        device array
        """
        return self._column.as_mask()

    def astype(self, dtype, copy=False, errors="raise"):
        """
        Cast the Series to the given dtype

        Parameters
        ----------

        dtype : data type, or dict of column name -> data type
            Use a numpy.dtype or Python type to cast Series object to
            the same type. Alternatively, use {col: dtype, ...}, where col is a
            series name and dtype is a numpy.dtype or Python type to cast to.
        copy : bool, default False
            Return a deep-copy when ``copy=True``. Note by default
            ``copy=False`` setting is used and hence changes to
            values then may propagate to other cudf objects.
        errors : {'raise', 'ignore', 'warn'}, default 'raise'
            Control raising of exceptions on invalid data for provided dtype.
            - ``raise`` : allow exceptions to be raised
            - ``ignore`` : suppress exceptions. On error return original
            object.
            - ``warn`` : prints last exceptions as warnings and
            return original object.

        Returns
        -------
        out : Series
            Returns ``self.copy(deep=copy)`` if ``dtype`` is the same
            as ``self.dtype``.
        """
        if errors not in ("ignore", "raise", "warn"):
            raise ValueError("invalid error value specified")

        if is_dict_like(dtype):
            if len(dtype) > 1 or self.name not in dtype:
                raise KeyError(
                    "Only the Series name can be used for "
                    "the key in Series dtype mappings."
                )
            dtype = dtype[self.name]

        if pd.api.types.is_dtype_equal(dtype, self.dtype):
            return self.copy(deep=copy)
        try:
            data = self._column.astype(dtype)

            return self._copy_construct(
                data=data.copy(deep=True) if copy else data, index=self.index
            )

        except Exception as e:
            if errors == "raise":
                raise e
            elif errors == "warn":
                import traceback

                tb = traceback.format_exc()
                warnings.warn(tb)
            elif errors == "ignore":
                pass
            return self

    def argsort(self, ascending=True, na_position="last"):
        """Returns a Series of int64 index that will sort the series.

        Uses Thrust sort.

        Returns
        -------
        result: Series
        """
        return self._sort(ascending=ascending, na_position=na_position)[1]

    def sort_index(self, ascending=True):
        """Sort by the index.
        """
        inds = self.index.argsort(ascending=ascending)
        return self.take(inds)

    def sort_values(
        self,
        axis=0,
        ascending=True,
        inplace=False,
        kind="quicksort",
        na_position="last",
        ignore_index=False,
    ):
        """
        Sort by the values.

        Sort a Series in ascending or descending order by some criterion.

        Parameters
        ----------
        ascending : bool, default True
            If True, sort values in ascending order, otherwise descending.
        na_position : {‘first’, ‘last’}, default ‘last’
            'first' puts nulls at the beginning, 'last' puts nulls at the end.
        ignore_index : bool, default False
            If True, index will not be sorted.

        Returns
        -------
        sorted_obj : cuDF Series

        Notes
        -----
        Difference from pandas:
          * Not supporting: `inplace`, `kind`

        Examples
        --------
        >>> import cudf
        >>> s = cudf.Series([1, 5, 2, 4, 3])
        >>> s.sort_values()
        0    1
        2    2
        4    3
        3    4
        1    5
        """

        if inplace:
            raise NotImplementedError("`inplace` not currently implemented.")
        if kind != "quicksort":
            raise NotImplementedError("`kind` not currently implemented.")
        if axis != 0:
            raise NotImplementedError("`axis` not currently implemented.")

        if len(self) == 0:
            return self
        vals, inds = self._sort(ascending=ascending, na_position=na_position)
        if not ignore_index:
            index = self.index.take(inds)
        else:
            index = self.index
        return vals.set_index(index)

    def _n_largest_or_smallest(self, largest, n, keep):
        direction = largest
        if keep == "first":
            if n < 0:
                n = 0
            return self.sort_values(ascending=not direction).head(n)
        elif keep == "last":
            data = self.sort_values(ascending=direction)
            if n <= 0:
                data = data[-n:-n]
            else:
                data = data.tail(n)
            return data.reverse()
        else:
            raise ValueError('keep must be either "first", "last"')

    def nlargest(self, n=5, keep="first"):
        """Returns a new Series of the *n* largest element.
        """
        return self._n_largest_or_smallest(n=n, keep=keep, largest=True)

    def nsmallest(self, n=5, keep="first"):
        """Returns a new Series of the *n* smallest element.
        """
        return self._n_largest_or_smallest(n=n, keep=keep, largest=False)

    def _sort(self, ascending=True, na_position="last"):
        """
        Sort by values

        Returns
        -------
        2-tuple of key and index
        """
        col_keys, col_inds = self._column.sort_by_values(
            ascending=ascending, na_position=na_position
        )
        sr_keys = self._copy_construct(data=col_keys)
        sr_inds = self._copy_construct(data=col_inds)
        return sr_keys, sr_inds

    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
        --------

        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
        """
        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 is_dict_like(to_replace) and value is not None:
            raise ValueError(
                "Series.replace cannot use dict-like to_replace and non-None "
                "value"
            )

        result = super().replace(to_replace=to_replace, replacement=value)

        return self._mimic_inplace(result, inplace=inplace)

    def reverse(self):
        """Reverse the Series
        """
        rinds = column.arange((self._column.size - 1), -1, -1, dtype=np.int32)
        col = self._column[rinds]
        index = self.index._values[rinds]
        return self._copy_construct(data=col, index=index)

    def one_hot_encoding(self, cats, dtype="float64"):
        """Perform one-hot-encoding

        Parameters
        ----------
        cats : sequence of values
                values representing each category.
        dtype : numpy.dtype
                specifies the output dtype.

        Returns
        -------
        Sequence
            A sequence of new series for each category. Its length is
            determined by the length of ``cats``.
        """
        if hasattr(cats, "to_arrow"):
            cats = cats.to_pandas()
        else:
            cats = pd.Series(cats, dtype="object")
        dtype = np.dtype(dtype)

        def encode(cat):
            if cat is None:
                if self.dtype.kind == "f":
                    # Need to ignore `np.nan` values incase
                    # of a float column
                    return self.__class__(
                        libcudf.unary.is_null((self._column))
                    )
                else:
                    return self.isnull()
            elif np.issubdtype(type(cat), np.floating) and np.isnan(cat):
                return self.__class__(libcudf.unary.is_nan(self._column))
            else:
                return (self == cat).fillna(False)

        return [encode(cat).astype(dtype) for cat in cats]

    def label_encoding(self, cats, dtype=None, na_sentinel=-1):
        """Perform label encoding

        Parameters
        ----------
        values : sequence of input values
        dtype : numpy.dtype; optional
            Specifies the output dtype.  If `None` is given, the
            smallest possible integer dtype (starting with np.int8)
            is used.
        na_sentinel : number, default -1
            Value to indicate missing category.

        Returns
        -------
        A sequence of encoded labels with value between 0 and n-1 classes(cats)

        Examples
        --------
        >>> import cudf
        >>> s = cudf.Series([1, 2, 3, 4, 10])
        >>> s.label_encoding([2, 3])
        0   -1
        1    0
        2    1
        3   -1
        4   -1
        dtype: int8

        `na_sentinel` parameter can be used to
        control the value when there is no encoding.

        >>> s.label_encoding([2, 3], na_sentinel=10)
        0    10
        1     0
        2     1
        3    10
        4    10
        dtype: int8

        When none of `cats` values exist in s, entire
        Series will be `na_sentinel`.

        >>> s.label_encoding(['a', 'b', 'c'])
        0   -1
        1   -1
        2   -1
        3   -1
        4   -1
        dtype: int8
        """

        def _return_sentinel_series():
            return Series(
                cudf.core.column.full(
                    size=len(self), fill_value=na_sentinel, dtype=dtype
                ),
                index=self.index,
                name=None,
            )

        if dtype is None:
            dtype = min_scalar_type(max(len(cats), na_sentinel), 8)

        cats = column.as_column(cats)
        if is_mixed_with_object_dtype(self, cats):
            return _return_sentinel_series()

        try:
            # Where there is a type-cast failure, we have
            # to catch the exception and return encoded labels
            # with na_sentinel values as there would be no corresponding
            # encoded values of cats in self.
            cats = cats.astype(self.dtype)
        except ValueError:
            return _return_sentinel_series()

        order = column.arange(len(self))
        codes = column.arange(len(cats), dtype=dtype)

        value = cudf.DataFrame({"value": cats, "code": codes})
        codes = cudf.DataFrame(
            {"value": self._data.columns[0].copy(deep=False), "order": order}
        )

        codes = codes.merge(value, on="value", how="left")
        codes = codes.sort_values("order")["code"].fillna(na_sentinel)

        return codes._copy_construct(name=None, index=self.index)

    def factorize(self, na_sentinel=-1):
        """Encode the input values as integer labels

        Parameters
        ----------
        na_sentinel : number
            Value to indicate missing category.

        Returns
        --------
        (labels, cats) : (Series, Series)
            - *labels* contains the encoded values
            - *cats* contains the categories in order that the N-th
              item corresponds to the (N-1) code.

        Examples
        --------
        >>> import cudf
        >>> s = cudf.Series(['a', 'a', 'c'])
        >>> codes, uniques = s.factorize()
        >>> codes
        0    0
        1    0
        2    1
        dtype: int8
        >>> uniques
        0    a
        1    c
        dtype: object
        """
        return cudf.core.algorithms.factorize(self, na_sentinel=na_sentinel)

    # UDF related

    def applymap(self, udf, out_dtype=None):
        """Apply an elementwise function to transform the values in the Column.

        The user function is expected to take one argument and return the
        result, which will be stored to the output Series.  The function
        cannot reference globals except for other simple scalar objects.

        Parameters
        ----------
        udf : function
            Either a callable python function or a python function already
            decorated by ``numba.cuda.jit`` for call on the GPU as a device

        out_dtype  : numpy.dtype; optional
            The dtype for use in the output.
            Only used for ``numba.cuda.jit`` decorated udf.
            By default, the result will have the same dtype as the source.

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

        Notes
        -----
        The supported Python features are listed in

          https://numba.pydata.org/numba-doc/dev/cuda/cudapysupported.html

        with these exceptions:

        * Math functions in `cmath` are not supported since `libcudf` does not
          have complex number support and output of `cmath` functions are most
          likely complex numbers.

        * These five functions in `math` are not supported since numba
          generates multiple PTX functions from them

          * math.sin()
          * math.cos()
          * math.tan()
          * math.gamma()
          * math.lgamma()

        * Series with string dtypes are not supported in `applymap` method.

        * Global variables need to be re-defined explicitly inside
          the udf, as numba considers them to be compile-time constants
          and there is no known way to obtain value of the global variable.

        Examples
        --------
        Returning a Series of booleans using only a literal pattern.

        >>> import cudf
        >>> s = cudf.Series([1, 10, -10, 200, 100])
        >>> s.applymap(lambda x: x)
        0      1
        1     10
        2    -10
        3    200
        4    100
        dtype: int64
        >>> s.applymap(lambda x: x in [1, 100, 59])
        0     True
        1    False
        2    False
        3    False
        4     True
        dtype: bool
        >>> s.applymap(lambda x: x ** 2)
        0        1
        1      100
        2      100
        3    40000
        4    10000
        dtype: int64
        >>> s.applymap(lambda x: (x ** 2) + (x / 2))
        0        1.5
        1      105.0
        2       95.0
        3    40100.0
        4    10050.0
        dtype: float64
        >>> def cube_function(a):
        ...     return a ** 3
        ...
        >>> s.applymap(cube_function)
        0          1
        1       1000
        2      -1000
        3    8000000
        4    1000000
        dtype: int64
        >>> def custom_udf(x):
        ...     if x > 0:
        ...         return x + 5
        ...     else:
        ...         return x - 5
        ...
        >>> s.applymap(custom_udf)
        0      6
        1     15
        2    -15
        3    205
        4    105
        dtype: int64
        """
        if is_string_dtype(self._column.dtype) or isinstance(
            self._column, cudf.core.column.CategoricalColumn
        ):
            raise TypeError(
                "User defined functions are currently not "
                "supported on Series with dtypes `str` and `category`."
            )

        if callable(udf):
            res_col = self._unaryop(udf)
        else:
            res_col = self._column.applymap(udf, out_dtype=out_dtype)
        return self._copy_construct(data=res_col)

    #
    # Stats
    #
    def count(self, level=None, **kwargs):
        """
        Return number of non-NA/null observations in the Series

        Returns
        -------
        int
            Number of non-null values in the Series.

        Notes
        -----
        Parameters currently not supported is `level`.

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.count()
        5
        """

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

        return self.valid_count

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

        Parameters
        ----------

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

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

        Returns
        -------
        scalar

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

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.min()
        1
        """

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if numeric_only not in (None, True):
            raise NotImplementedError(
                "numeric_only parameter is not implemented yet"
            )

        return self._column.min(skipna=skipna, dtype=dtype)

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

        Parameters
        ----------

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

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

        Returns
        -------
        scalar

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

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.max()
        5
        """

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if numeric_only not in (None, True):
            raise NotImplementedError(
                "numeric_only parameter is not implemented yet"
            )

        return self._column.max(skipna=skipna, dtype=dtype)

    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 Series.

        Parameters
        ----------

        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
        -------
        scalar

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

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.sum()
        15
        """

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if numeric_only not in (None, True):
            raise NotImplementedError(
                "numeric_only parameter is not implemented yet"
            )

        return self._column.sum(
            skipna=skipna, dtype=dtype, min_count=min_count
        )

    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 Series.

        Parameters
        ----------

        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
        -------
        scalar

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

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.product()
        120
        """

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if numeric_only not in (None, True):
            raise NotImplementedError(
                "numeric_only parameter is not implemented yet"
            )

        return self._column.product(
            skipna=skipna, dtype=dtype, min_count=min_count
        )

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

        Parameters
        ----------

        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
        -------
        scalar

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

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.prod()
        120
        """
        return self.product(
            axis=axis,
            skipna=skipna,
            dtype=dtype,
            level=level,
            numeric_only=numeric_only,
            min_count=min_count,
            **kwargs,
        )

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

        Parameters
        ----------

        skipna : bool, default True
            Exclude NA/null values. If an entire row/column is NA,
            the result will be NA.

        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported is `axis`

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

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

        skipna = True if skipna is None else skipna

        if skipna:
            result_col = self.nans_to_nulls()._column
        else:
            result_col = self._column.copy()
            if result_col.has_nulls:
                # 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

        return Series(
            result_col._apply_scan_op("min"), name=self.name, index=self.index,
        )

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

        Parameters
        ----------

        skipna : bool, default True
            Exclude NA/null values. If an entire row/column is NA,
            the result will be NA.

        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported is `axis`

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([1, 5, 2, 4, 3])
        >>> ser.cummax()
        0    1
        1    5
        2    5
        3    5
        4    5
        """
        assert axis in (None, 0)

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

        skipna = True if skipna is None else skipna

        if skipna:
            result_col = self.nans_to_nulls()._column
        else:
            result_col = self._column.copy()
            if result_col.has_nulls:
                first_index = int(
                    result_col.isnull().astype("int8").find_first_value(1)
                )
                result_col[first_index:] = None

        return Series(
            result_col._apply_scan_op("max"), name=self.name, index=self.index,
        )

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

        Parameters
        ----------

        skipna : bool, default True
            Exclude NA/null values. If an entire row/column is NA,
            the result will be NA.


        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported is `axis`

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

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

        skipna = True if skipna is None else skipna

        if skipna:
            result_col = self.nans_to_nulls()._column
        else:
            result_col = self._column.copy()
            if result_col.has_nulls:
                first_index = int(
                    result_col.isnull().astype("int8").find_first_value(1)
                )
                result_col[first_index:] = None

        # pandas always returns int64 dtype if original dtype is int or `bool`
        if np.issubdtype(result_col.dtype, np.integer) or np.issubdtype(
            result_col.dtype, np.bool_
        ):
            return Series(
                result_col.astype(np.int64)._apply_scan_op("sum"),
                name=self.name,
                index=self.index,
            )
        else:
            return Series(
                result_col._apply_scan_op("sum"),
                name=self.name,
                index=self.index,
            )

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

        Parameters
        ----------

        skipna : bool, default True
            Exclude NA/null values. If an entire row/column is NA,
            the result will be NA.

        Returns
        -------
        Series

        Notes
        -----
        Parameters currently not supported is `axis`

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

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

        skipna = True if skipna is None else skipna

        if skipna:
            result_col = self.nans_to_nulls()._column
        else:
            result_col = self._column.copy()
            if result_col.has_nulls:
                first_index = int(
                    result_col.isnull().astype("int8").find_first_value(1)
                )
                result_col[first_index:] = None

        # pandas always returns int64 dtype if original dtype is int or `bool`
        if np.issubdtype(result_col.dtype, np.integer) or np.issubdtype(
            result_col.dtype, np.bool_
        ):
            return Series(
                result_col.astype(np.int64)._apply_scan_op("product"),
                name=self.name,
                index=self.index,
            )
        else:
            return Series(
                result_col._apply_scan_op("product"),
                name=self.name,
                index=self.index,
            )

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

        Parameters
        ----------

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

        Returns
        -------
        scalar

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

        Examples
        --------
        >>> import cudf
        >>> ser = cudf.Series([10, 25, 3, 25, 24, 6])
        >>> ser.mean()
        15.5
        """

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if numeric_only not in (None, True):
            raise NotImplementedError(
                "numeric_only parameter is not implemented yet"
            )

        return self._column.mean(skipna=skipna)

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

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

        Parameters
        ----------

        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 `axis`, `level` and
        `numeric_only`
        """

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if numeric_only not in (None, True):
            raise NotImplementedError(
                "numeric_only parameter is not implemented yet"
            )

        return self._column.std(skipna=skipna, ddof=ddof)

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

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

        Parameters
        ----------

        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 `axis`, `level` and
        `numeric_only`
        """

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if numeric_only not in (None, True):
            raise NotImplementedError(
                "numeric_only parameter is not implemented yet"
            )

        return self._column.var(skipna=skipna, ddof=ddof)

    def sum_of_squares(self, dtype=None):
        return self._column.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 `axis`, `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
        """
        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if numeric_only not in (None, True):
            raise NotImplementedError(
                "numeric_only parameter is not implemented yet"
            )

        return self._column.median(skipna=skipna)

    def mode(self, dropna=True):
        """
        Return the mode(s) of the dataset.

        Always returns Series even if only one value is returned.

        Parameters
        ----------
        dropna : bool, default True
            Don't consider counts of NA/NaN/NaT.

        Returns
        -------
        Series
            Modes of the Series in sorted order.

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

        We can include ``<NA>`` values in mode by
        passing ``dropna=False``.

        >>> series = cudf.Series([7, 4, 3, 3, 7, None, None])
        >>> series
        0       7
        1       4
        2       3
        3       3
        4       7
        5    <NA>
        6    <NA>
        dtype: int64
        >>> series.mode()
        0    3
        1    7
        dtype: int64
        >>> series.mode(dropna=False)
        0       3
        1       7
        2    <NA>
        dtype: int64
        """
        val_counts = self.value_counts(ascending=False, dropna=dropna)
        if len(val_counts) > 0:
            val_counts = val_counts[val_counts == val_counts.iloc[0]]

        return Series(val_counts.index.sort_values(), name=self.name)

    def round(self, decimals=0):
        """
        Round each value in a Series to the given number of decimals.

        Parameters
        ----------
        decimals : int, default 0
            Number of decimal places to round to. If decimals is negative,
            it specifies the number of positions to the left of the decimal
            point.

        Returns
        -------
        Series
            Rounded values of the Series.

        Examples
        --------
        >>> s = cudf.Series([0.1, 1.4, 2.9])
        >>> s.round()
        0    0.0
        1    1.0
        2    3.0
        dtype: float64
        """

        return Series(
            self._column.round(decimals=decimals),
            name=self.name,
            index=self.index,
            dtype=self.dtype,
        )

    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
        ----------

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

        Returns
        -------
        scalar

        Notes
        -----
        Parameters currently not supported are `axis`, `level` and
        `numeric_only`
        """
        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if numeric_only not in (None, True):
            raise NotImplementedError(
                "numeric_only parameter is not implemented yet"
            )

        return self._column.kurtosis(skipna=skipna)

    # Alias for kurtosis.
    kurt = kurtosis

    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
        -------
        scalar

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

        if axis not in (None, 0):
            raise NotImplementedError("axis parameter is not implemented yet")

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

        if numeric_only not in (None, True):
            raise NotImplementedError(
                "numeric_only parameter is not implemented yet"
            )

        return self._column.skew(skipna=skipna)

    def cov(self, other, min_periods=None):
        """
        Compute covariance with Series, excluding missing values.

        Parameters
        ----------
        other : Series
            Series with which to compute the covariance.

        Returns
        -------
        float
            Covariance between Series and other normalized by N-1
            (unbiased estimator).

        Notes
        -----
        `min_periods` parameter is not yet supported.

        Examples
        --------
        >>> import cudf
        >>> ser1 = cudf.Series([0.9, 0.13, 0.62])
        >>> ser2 = cudf.Series([0.12, 0.26, 0.51])
        >>> ser1.cov(ser2)
        -0.015750000000000004
        """

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

        if self.empty or other.empty:
            return cudf.utils.dtypes._get_nan_for_dtype(self.dtype)

        lhs = self.nans_to_nulls().dropna()
        rhs = other.nans_to_nulls().dropna()

        lhs, rhs = _align_indices([lhs, rhs], how="inner")

        return lhs._column.cov(rhs._column)

    def corr(self, other, method="pearson", min_periods=None):
        """Calculates the sample correlation between two Series,
        excluding missing values.

        Examples
        --------
        >>> import cudf
        >>> ser1 = cudf.Series([0.9, 0.13, 0.62])
        >>> ser2 = cudf.Series([0.12, 0.26, 0.51])
        >>> ser1.corr(ser2)
        -0.20454263717316112
        """

        assert method in ("pearson",) and min_periods in (None,)

        if self.empty or other.empty:
            return cudf.utils.dtypes._get_nan_for_dtype(self.dtype)

        lhs = self.nans_to_nulls().dropna()
        rhs = other.nans_to_nulls().dropna()
        lhs, rhs = _align_indices([lhs, rhs], how="inner")

        return lhs._column.corr(rhs._column)

    def isin(self, values):
        """Check whether values are contained in Series.

        Parameters
        ----------
        values : set or list-like
            The sequence of values to test. Passing in a single string will
            raise a TypeError. Instead, turn a single string into a list
            of one element.

        Returns
        -------
        result : Series
            Series of booleans indicating if each element is in values.

        Raises
        -------
        TypeError
            If values is a string
        """

        if is_scalar(values):
            raise TypeError(
                "only list-like objects are allowed to be passed "
                f"to isin(), you passed a [{type(values).__name__}]"
            )

        return Series(
            self._column.isin(values), index=self.index, name=self.name
        )

    def unique(self):
        """
        Returns unique values of this Series.
        """
        res = self._column.unique()
        return Series(res, name=self.name)

    def nunique(self, method="sort", dropna=True):
        """Returns the number of unique values of the Series: approximate version,
        and exact version to be moved to libgdf
        """
        if method != "sort":
            msg = "non sort based distinct_count() not implemented yet"
            raise NotImplementedError(msg)
        if self.null_count == len(self):
            return 0
        return self._column.distinct_count(method, dropna)

    def value_counts(
        self,
        normalize=False,
        sort=True,
        ascending=False,
        bins=None,
        dropna=True,
    ):
        """Return a Series containing counts of unique values.

        The resulting object will be in descending order so that
        the first element is the most frequently-occurring element.
        Excludes NA values by default.

        Parameters
        ----------
        normalize : bool, default False
            If True then the object returned will contain
            the relative frequencies of the unique values.

        sort : bool, default True
            Sort by frequencies.

        ascending : bool, default False
            Sort in ascending order.

        bins : int, optional
            Rather than count values, group them into half-open bins,
            works with numeric data. This Parameter is not yet supported.

        dropna : bool, default True
            Don’t include counts of NaN and None.

        Returns
        -------
        result : Series contanining counts of unique values.

        See also
        --------
        Series.count
            Number of non-NA elements in a Series.

        cudf.core.dataframe.DataFrame.count
            Number of non-NA elements in a DataFrame.

        Examples
        --------
        >>> import cudf
        >>> sr = cudf.Series([1.0, 2.0, 2.0, 3.0, 3.0, 3.0, None])
        >>> sr
        0     1.0
        1     2.0
        2     2.0
        3     3.0
        4     3.0
        5     3.0
        6    <NA>
        dtype: float64
        >>> sr.value_counts()
        3.0    3
        2.0    2
        1.0    1
        dtype: int32

        The order of the counts can be changed by passing ``ascending=True``:

        >>> sr.value_counts(ascending=True)
        1.0    1
        2.0    2
        3.0    3
        dtype: int32

        With ``normalize`` set to True, returns the relative frequency
        by dividing all values by the sum of values.

        >>> sr.value_counts(normalize=True)
        3.0    0.500000
        2.0    0.333333
        1.0    0.166667
        dtype: float64

        To include ``NA`` value counts, pass ``dropna=False``:

        >>> sr = cudf.Series([1.0, 2.0, 2.0, 3.0, None, 3.0, 3.0, None])
        >>> sr
        0     1.0
        1     2.0
        2     2.0
        3     3.0
        4    <NA>
        5     3.0
        6     3.0
        7    <NA>
        dtype: float64
        >>> sr.value_counts(dropna=False)
        3.0     3
        2.0     2
        <NA>    2
        1.0     1
        dtype: int32
        """

        if bins is not None:
            raise NotImplementedError("bins is not yet supported")

        if dropna and self.null_count == len(self):
            return Series(
                [],
                dtype=np.int32,
                name=self.name,
                index=cudf.Index([], dtype=self.dtype),
            )

        res = self.groupby(self, dropna=dropna).count(dropna=dropna)
        res.index.name = None

        if sort:
            res = res.sort_values(ascending=ascending)

        if normalize:
            res = res / float(res._column.sum())
        return res

    def scale(self):
        """Scale values to [0, 1] in float64
        """
        vmin = self.min()
        vmax = self.max()
        scaled = (self - vmin) / (vmax - vmin)
        return self._copy_construct(data=scaled)

    # Absolute
    def abs(self):
        """Absolute value of each element of the series.

        Returns a new Series.
        """
        return self._unaryop("abs")

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

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

        Returns a new Series.
        """
        return self._unaryop("ceil")

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

        Returns a new Series.
        """
        return self._unaryop("floor")

    def hash_values(self):
        """Compute the hash of values in this column.
        """
        return Series(self._hash()).values

    def hash_encode(self, stop, use_name=False):
        """Encode column values as ints in [0, stop) using hash function.

        Parameters
        ----------
        stop : int
            The upper bound on the encoding range.
        use_name : bool
            If ``True`` then combine hashed column values
            with hashed column name. This is useful for when the same
            values in different columns should be encoded
            with different hashed values.

        Returns
        -------
        result : Series
            The encoded Series.
        """
        assert stop > 0

        initial_hash = [hash(self.name) & 0xFFFFFFFF] if use_name else None
        hashed_values = Series(self._hash(initial_hash))

        if hashed_values.has_nulls:
            raise ValueError("Column must have no nulls.")

        mod_vals = hashed_values % stop
        return Series(mod_vals._column, index=self.index, name=self.name)

    def quantile(
        self, q=0.5, interpolation="linear", exact=True, quant_index=True
    ):
        """
        Return values at the given quantile.

        Parameters
        ----------

        q : float or array-like, default 0.5 (50% quantile)
            0 <= q <= 1, the quantile(s) to compute
        interpolation : {’linear’, ‘lower’, ‘higher’, ‘midpoint’, ‘nearest’}
            This optional parameter specifies the interpolation method to use,
            when the desired quantile lies between two data points i and j:
        columns : list of str
            List of column names to include.
        exact : boolean
            Whether to use approximate or exact quantile algorithm.
        quant_index : boolean
            Whether to use the list of quantiles as index.

        Returns
        -------
        float or Series
            If ``q`` is an array, a Series will be returned where the
            index is ``q`` and the values are the quantiles, otherwise
            a float will be returned.
        """

        result = self._column.quantile(q, interpolation, exact)

        if isinstance(q, Number):
            return result

        if quant_index:
            index = np.asarray(q)
            if len(self) == 0:
                result = column_empty_like(
                    index, dtype=self.dtype, masked=True, newsize=len(index),
                )
        else:
            index = None

        return Series(result, index=index, name=self.name)

    @docutils.doc_describe()
    def describe(
        self,
        percentiles=None,
        include=None,
        exclude=None,
        datetime_is_numeric=False,
    ):
        """{docstring}"""

        def _prepare_percentiles(percentiles):
            percentiles = list(percentiles)

            if not all(0 <= x <= 1 for x in percentiles):
                raise ValueError(
                    "All percentiles must be between 0 and 1, " "inclusive."
                )

            # describe always includes 50th percentile
            if 0.5 not in percentiles:
                percentiles.append(0.5)

            percentiles = np.sort(percentiles)
            return percentiles

        def _format_percentile_names(percentiles):
            return ["{0}%".format(int(x * 100)) for x in percentiles]

        def _format_stats_values(stats_data):
            return list(map(lambda x: round(x, 6), stats_data))

        def _describe_numeric(self):
            # mimicking pandas
            index = (
                ["count", "mean", "std", "min"]
                + _format_percentile_names(percentiles)
                + ["max"]
            )
            data = (
                [self.count(), self.mean(), self.std(), self.min()]
                + self.quantile(percentiles).to_array(fillna="pandas").tolist()
                + [self.max()]
            )
            data = _format_stats_values(data)

            return Series(
                data=data, index=index, nan_as_null=False, name=self.name,
            )

        def _describe_timedelta(self):
            # mimicking pandas
            index = (
                ["count", "mean", "std", "min"]
                + _format_percentile_names(percentiles)
                + ["max"]
            )

            data = (
                [
                    str(self.count()),
                    str(self.mean()),
                    str(self.std()),
                    str(pd.Timedelta(self.min())),
                ]
                + self.quantile(percentiles)
                .astype("str")
                .to_array(fillna="pandas")
                .tolist()
                + [str(pd.Timedelta(self.max()))]
            )

            return Series(
                data=data,
                index=index,
                dtype="str",
                nan_as_null=False,
                name=self.name,
            )

        def _describe_categorical(self):
            # blocked by StringColumn/DatetimeColumn support for
            # value_counts/unique
            index = ["count", "unique", "top", "freq"]
            val_counts = self.value_counts(ascending=False)
            data = [self.count(), self.unique().size]

            if data[1] > 0:
                top, freq = val_counts.index[0], val_counts.iloc[0]
                data += [str(top), freq]
            # If the DataFrame is empty, set 'top' and 'freq' to None
            # to maintain output shape consistency
            else:
                data += [None, None]

            return Series(
                data=data,
                dtype="str",
                index=index,
                nan_as_null=False,
                name=self.name,
            )

        def _describe_timestamp(self):

            index = (
                ["count", "mean", "min"]
                + _format_percentile_names(percentiles)
                + ["max"]
            )

            data = (
                [
                    str(self.count()),
                    str(self.mean().to_numpy().astype("datetime64[ns]")),
                    str(pd.Timestamp(self.min().astype("datetime64[ns]"))),
                ]
                + self.quantile(percentiles)
                .astype("str")
                .to_array(fillna="pandas")
                .tolist()
                + [str(pd.Timestamp((self.max()).astype("datetime64[ns]")))]
            )

            return Series(
                data=data,
                dtype="str",
                index=index,
                nan_as_null=False,
                name=self.name,
            )

        if percentiles is not None:
            percentiles = _prepare_percentiles(percentiles)
        else:
            # pandas defaults
            percentiles = np.array([0.25, 0.5, 0.75])

        if pd.api.types.is_bool_dtype(self.dtype):
            return _describe_categorical(self)
        elif isinstance(self._column, cudf.core.column.NumericalColumn):
            return _describe_numeric(self)
        elif isinstance(self._column, cudf.core.column.TimeDeltaColumn):
            return _describe_timedelta(self)
        elif isinstance(self._column, cudf.core.column.DatetimeColumn):
            return _describe_timestamp(self)
        else:
            return _describe_categorical(self)

    def digitize(self, bins, right=False):
        """Return the indices of the bins to which each value in series belongs.

        Notes
        -----
        Monotonicity of bins is assumed and not checked.

        Parameters
        ----------
        bins : np.array
            1-D monotonically, increasing array with same type as this series.
        right : bool
            Indicates whether interval contains the right or left bin edge.

        Returns
        -------
        A new Series containing the indices.
        """
        return Series(
            cudf.core.column.numerical.digitize(self._column, bins, right)
        )

    def diff(self, periods=1):
        """Calculate the difference between values at positions i and i - N in
        an array and store the output in a new array.

        Notes
        -----
        Diff currently only supports float and integer dtype columns with
        no null values.
        """
        if self.has_nulls:
            raise AssertionError(
                "Diff currently requires columns with no null values"
            )

        if not np.issubdtype(self.dtype, np.number):
            raise NotImplementedError(
                "Diff currently only supports numeric dtypes"
            )

        # TODO: move this libcudf
        input_col = self._column
        output_col = column_empty_like(input_col)
        output_mask = column_empty_like(input_col, dtype="bool")
        if output_col.size > 0:
            cudautils.gpu_diff.forall(output_col.size)(
                input_col, output_col, output_mask, periods
            )

        output_col = column.build_column(
            data=output_col.data,
            dtype=output_col.dtype,
            mask=bools_to_mask(output_mask),
        )

        return Series(output_col, name=self.name, index=self.index)

    @copy_docstring(SeriesGroupBy.__init__)
    def groupby(
        self,
        by=None,
        axis=0,
        level=None,
        as_index=True,
        sort=False,
        group_keys=True,
        squeeze=False,
        observed=False,
        dropna=True,
    ):
        if axis not in (0, "index"):
            raise NotImplementedError("axis parameter is not yet implemented")

        if group_keys is not True:
            raise NotImplementedError(
                "The group_keys keyword is not yet implemented"
            )

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

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

        if by is None and level is None:
            raise TypeError(
                "groupby() requires either by or level to be specified."
            )

        return SeriesGroupBy(
            self, by=by, level=level, dropna=dropna, sort=sort
        )

    @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,
        )

    @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 rename(self, index=None, copy=True):
        """
        Alter Series name

        Change Series.name with a scalar value

        Parameters
        ----------
        index : Scalar, optional
            Scalar to alter the Series.name attribute
        copy : boolean, default True
            Also copy underlying data

        Returns
        -------
        Series

        Notes
        -----
        Difference from pandas:
          - Supports scalar values only for changing name attribute
          - Not supporting : inplace, level
        """
        out = self.copy(deep=False)
        out = out.set_index(self.index)
        if index:
            out.name = index

        return out.copy(deep=copy)

    @property
    def is_unique(self):
        """
        Return boolean if values in the object are unique.

        Returns
        -------
        out : bool
        """
        return self._column.is_unique

    @property
    def is_monotonic(self):
        """
        Return boolean if values in the object are monotonic_increasing.

        Returns
        -------
        out : bool
        """
        return self._column.is_monotonic_increasing

    @property
    def is_monotonic_increasing(self):
        """
        Return boolean if values in the object are monotonic_increasing.

        Returns
        -------
        out : bool
        """
        return self._column.is_monotonic_increasing

    @property
    def is_monotonic_decreasing(self):
        """
        Return boolean if values in the object are monotonic_decreasing.

        Returns
        -------
        out : bool
        """
        return self._column.is_monotonic_decreasing

    @property
    def __cuda_array_interface__(self):
        return self._column.__cuda_array_interface__

    def _align_to_index(
        self, index, how="outer", sort=True, allow_non_unique=False
    ):
        """
        Align to the given Index. See _align_indices below.
        """

        index = as_index(index)
        if self.index.equals(index):
            return self
        if not allow_non_unique:
            if len(self) != len(self.index.unique()) or len(index) != len(
                index.unique()
            ):
                raise ValueError("Cannot align indices with non-unique values")
        lhs = self.to_frame(0)
        rhs = cudf.DataFrame(index=as_index(index))
        if how == "left":
            tmp_col_id = str(uuid4())
            lhs[tmp_col_id] = column.arange(len(lhs))
        elif how == "right":
            tmp_col_id = str(uuid4())
            rhs[tmp_col_id] = column.arange(len(rhs))
        result = lhs.join(rhs, how=how, sort=sort)
        if how == "left" or how == "right":
            result = result.sort_values(tmp_col_id)[0]
        else:
            result = result[0]

        result.name = self.name
        result.index.names = index.names
        return result

    def merge(
        self,
        other,
        on=None,
        left_on=None,
        right_on=None,
        left_index=False,
        right_index=False,
        how="inner",
        sort=False,
        lsuffix=None,
        rsuffix=None,
        method="hash",
        suffixes=("_x", "_y"),
    ):

        if left_on not in (self.name, None):
            raise ValueError(
                "Series to other merge uses series name as key implicitly"
            )

        lhs = self.copy(deep=False)
        rhs = other.copy(deep=False)

        result = super(Series, lhs)._merge(
            rhs,
            on=on,
            left_on=left_on,
            right_on=right_on,
            left_index=left_index,
            right_index=right_index,
            how=how,
            sort=sort,
            lsuffix=lsuffix,
            rsuffix=rsuffix,
            method=method,
            indicator=False,
            suffixes=suffixes,
        )

        return result

    def keys(self):
        """
        Return alias for index.

        Returns
        -------
        Index
            Index of the Series.

        Examples
        --------
        >>> import cudf
        >>> sr = cudf.Series([10, 11, 12, 13, 14, 15])
        >>> sr
        0    10
        1    11
        2    12
        3    13
        4    14
        5    15
        dtype: int64

        >>> sr.keys()
        RangeIndex(start=0, stop=6)
        >>> sr = cudf.Series(['a', 'b', 'c'])
        >>> sr
        0    a
        1    b
        2    c
        dtype: object
        >>> sr.keys()
        RangeIndex(start=0, stop=3)
        >>> sr = cudf.Series([1, 2, 3], index=['a', 'b', 'c'])
        >>> sr
        a    1
        b    2
        c    3
        dtype: int64
        >>> sr.keys()
        StringIndex(['a' 'b' 'c'], dtype='object')
        """
        return self.index

    _accessors = set()  # type: Set[Any]


truediv_int_dtype_corrections = {
    "int8": "float32",
    "int16": "float32",
    "int32": "float32",
    "int64": "float64",
    "uint8": "float32",
    "uint16": "float32",
    "uint32": "float64",
    "uint64": "float64",
    "bool": "float32",
    "int": "float",
}


class DatetimeProperties(object):
    """
    Accessor object for datetimelike properties of the Series values.

    Returns
    -------
    Returns a Series indexed like the original Series.

    Examples
    --------
    >>> import cudf
    >>> import pandas as pd
    >>> seconds_series = cudf.Series(pd.date_range("2000-01-01", periods=3,
    ...     freq="s"))
    >>> seconds_series
    0   2000-01-01 00:00:00
    1   2000-01-01 00:00:01
    2   2000-01-01 00:00:02
    dtype: datetime64[ns]
    >>> seconds_series.dt.second
    0    0
    1    1
    2    2
    dtype: int16
    >>> hours_series = cudf.Series(pd.date_range("2000-01-01", periods=3,
    ...     freq="h"))
    >>> hours_series
    0   2000-01-01 00:00:00
    1   2000-01-01 01:00:00
    2   2000-01-01 02:00:00
    dtype: datetime64[ns]
    >>> hours_series.dt.hour
    0    0
    1    1
    2    2
    dtype: int16
    >>> weekday_series = cudf.Series(pd.date_range("2000-01-01", periods=3,
    ...     freq="q"))
    >>> weekday_series
    0   2000-03-31
    1   2000-06-30
    2   2000-09-30
    dtype: datetime64[ns]
    >>> weekday_series.dt.weekday
    0    4
    1    4
    2    5
    dtype: int16
    """

    def __init__(self, series):
        self.series = series

    @property
    def year(self):
        """
        The year of the datetime.

        Examples
        --------
        >>> import cudf
        >>> import pandas as pd
        >>> datetime_series = cudf.Series(pd.date_range("2000-01-01",
        ...         periods=3, freq="Y"))
        >>> datetime_series
        0   2000-12-31
        1   2001-12-31
        2   2002-12-31
        dtype: datetime64[ns]
        >>> datetime_series.dt.year
        0    2000
        1    2001
        2    2002
        dtype: int16
        """
        return self._get_dt_field("year")

    @property
    def month(self):
        """
        The month as January=1, December=12.

        Examples
        --------
        >>> import pandas as pd
        >>> import cudf
        >>> datetime_series = cudf.Series(pd.date_range("2000-01-01",
        ...         periods=3, freq="M"))
        >>> datetime_series
        0   2000-01-31
        1   2000-02-29
        2   2000-03-31
        dtype: datetime64[ns]
        >>> datetime_series.dt.month
        0    1
        1    2
        2    3
        dtype: int16
        """
        return self._get_dt_field("month")

    @property
    def day(self):
        """
        The day of the datetime.

        Examples
        --------
        >>> import pandas as pd
        >>> import cudf
        >>> datetime_series = cudf.Series(pd.date_range("2000-01-01",
        ...         periods=3, freq="D"))
        >>> datetime_series
        0   2000-01-01
        1   2000-01-02
        2   2000-01-03
        dtype: datetime64[ns]
        >>> datetime_series.dt.day
        0    1
        1    2
        2    3
        dtype: int16
        """
        return self._get_dt_field("day")

    @property
    def hour(self):
        """
        The hours of the datetime.

        Examples
        --------
        >>> import pandas as pd
        >>> import cudf
        >>> datetime_series = cudf.Series(pd.date_range("2000-01-01",
        ...         periods=3, freq="h"))
        >>> datetime_series
        0   2000-01-01 00:00:00
        1   2000-01-01 01:00:00
        2   2000-01-01 02:00:00
        dtype: datetime64[ns]
        >>> datetime_series.dt.hour
        0    0
        1    1
        2    2
        dtype: int16
        """
        return self._get_dt_field("hour")

    @property
    def minute(self):
        """
        The minutes of the datetime.

        Examples
        --------
        >>> import pandas as pd
        >>> import cudf
        >>> datetime_series = cudf.Series(pd.date_range("2000-01-01",
        ...         periods=3, freq="T"))
        >>> datetime_series
        0   2000-01-01 00:00:00
        1   2000-01-01 00:01:00
        2   2000-01-01 00:02:00
        dtype: datetime64[ns]
        >>> datetime_series.dt.minute
        0    0
        1    1
        2    2
        dtype: int16
        """
        return self._get_dt_field("minute")

    @property
    def second(self):
        """
        The seconds of the datetime.

        Examples
        --------
        >>> import pandas as pd
        >>> import cudf
        >>> datetime_series = cudf.Series(pd.date_range("2000-01-01",
        ...         periods=3, freq="s"))
        >>> datetime_series
        0   2000-01-01 00:00:00
        1   2000-01-01 00:00:01
        2   2000-01-01 00:00:02
        dtype: datetime64[ns]
        >>> datetime_series.dt.second
        0    0
        1    1
        2    2
        dtype: int16
        """
        return self._get_dt_field("second")

    @property
    def weekday(self):
        """
        The day of the week with Monday=0, Sunday=6.

        Examples
        --------
        >>> import pandas as pd
        >>> import cudf
        >>> datetime_series = cudf.Series(pd.date_range('2016-12-31',
        ...     '2017-01-08', freq='D'))
        >>> datetime_series
        0   2016-12-31
        1   2017-01-01
        2   2017-01-02
        3   2017-01-03
        4   2017-01-04
        5   2017-01-05
        6   2017-01-06
        7   2017-01-07
        8   2017-01-08
        dtype: datetime64[ns]
        >>> datetime_series.dt.weekday
        0    5
        1    6
        2    0
        3    1
        4    2
        5    3
        6    4
        7    5
        8    6
        dtype: int16
        """
        return self._get_dt_field("weekday")

    @property
    def dayofweek(self):
        """
        The day of the week with Monday=0, Sunday=6.

        Examples
        --------
        >>> import pandas as pd
        >>> import cudf
        >>> datetime_series = cudf.Series(pd.date_range('2016-12-31',
        ...     '2017-01-08', freq='D'))
        >>> datetime_series
        0   2016-12-31
        1   2017-01-01
        2   2017-01-02
        3   2017-01-03
        4   2017-01-04
        5   2017-01-05
        6   2017-01-06
        7   2017-01-07
        8   2017-01-08
        dtype: datetime64[ns]
        >>> datetime_series.dt.dayofweek
        0    5
        1    6
        2    0
        3    1
        4    2
        5    3
        6    4
        7    5
        8    6
        dtype: int16
        """
        return self._get_dt_field("weekday")

    def _get_dt_field(self, field):
        out_column = self.series._column.get_dt_field(field)
        return Series(
            data=out_column, index=self.series._index, name=self.series.name
        )

    def strftime(self, date_format, *args, **kwargs):
        """
        Convert to Series using specified ``date_format``.

        Return a Series of formatted strings specified by ``date_format``,
        which supports the same string format as the python standard library.
        Details of the string format can be found in `python string format doc
        <https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior>`_.

        Parameters
        ----------
        date_format : str
            Date format string (e.g. “%Y-%m-%d”).

        Returns
        -------
        Series
            Series of formatted strings.

        Notes
        -----

        The following date format identifiers are not yet supported: ``%a``,
        ``%A``, ``%w``, ``%b``, ``%B``, ``%U``, ``%W``, ``%c``, ``%x``,
        ``%X``, ``%G``, ``%u``, ``%V``

        Examples
        --------
        >>> import cudf
        >>> import pandas as pd
        >>> weekday_series = cudf.Series(pd.date_range("2000-01-01", periods=3,
        ...      freq="q"))
        >>> weekday_series.dt.strftime("%Y-%m-%d")
        >>> weekday_series
        0   2000-03-31
        1   2000-06-30
        2   2000-09-30
        dtype: datetime64[ns]
        0    2000-03-31
        1    2000-06-30
        2    2000-09-30
        dtype: object
        >>> weekday_series.dt.strftime("%Y %d %m")
        0    2000 31 03
        1    2000 30 06
        2    2000 30 09
        dtype: object
        >>> weekday_series.dt.strftime("%Y / %d / %m")
        0    2000 / 31 / 03
        1    2000 / 30 / 06
        2    2000 / 30 / 09
        dtype: object
        """

        if not isinstance(date_format, str):
            raise TypeError(
                f"'date_format' must be str, not {type(date_format)}"
            )

        # TODO: Remove following validations
        # once https://github.com/rapidsai/cudf/issues/5991
        # is implemented
        not_implemented_formats = {
            "%a",
            "%A",
            "%w",
            "%b",
            "%B",
            "%U",
            "%W",
            "%c",
            "%x",
            "%X",
            "%G",
            "%u",
            "%V",
        }
        for d_format in not_implemented_formats:
            if d_format in date_format:
                raise NotImplementedError(
                    f"{d_format} date-time format is not "
                    f"supported yet, Please follow this issue "
                    f"https://github.com/rapidsai/cudf/issues/5991 "
                    f"for tracking purposes."
                )

        str_col = self.series._column.as_string_column(
            dtype="str", format=date_format
        )
        return Series(
            data=str_col, index=self.series._index, name=self.series.name
        )


class TimedeltaProperties(object):
    """
    Accessor object for timedeltalike properties of the Series values.

    Returns
    -------
    Returns a Series indexed like the original Series.

    Examples
    --------
    >>> import cudf
    >>> seconds_series = cudf.Series([1, 2, 3], dtype='timedelta64[s]')
    >>> seconds_series
    0    00:00:01
    1    00:00:02
    2    00:00:03
    dtype: timedelta64[s]
    >>> seconds_series.dt.seconds
    0    1
    1    2
    2    3
    dtype: int64
    >>> series = cudf.Series([12231312123, 1231231231, 1123236768712, 2135656,
    ...     3244334234], dtype='timedelta64[ms]')
    >>> series
    0      141 days 13:35:12.123
    1       14 days 06:00:31.231
    2    13000 days 10:12:48.712
    3        0 days 00:35:35.656
    4       37 days 13:12:14.234
    dtype: timedelta64[ms]
    >>> series.dt.components
        days  hours  minutes  seconds  milliseconds  microseconds  nanoseconds
    0    141     13       35       12           123             0            0
    1     14      6        0       31           231             0            0
    2  13000     10       12       48           712             0            0
    3      0      0       35       35           656             0            0
    4     37     13       12       14           234             0            0
    >>> series.dt.days
    0      141
    1       14
    2    13000
    3        0
    4       37
    dtype: int64
    >>> series.dt.seconds
    0    48912
    1    21631
    2    36768
    3     2135
    4    47534
    dtype: int64
    >>> series.dt.microseconds
    0    123000
    1    231000
    2    712000
    3    656000
    4    234000
    dtype: int64
    >>> s.dt.nanoseconds
    0    0
    1    0
    2    0
    3    0
    4    0
    dtype: int64
    """

    def __init__(self, series):
        self.series = series

    @property
    def days(self):
        """
        Number of days.

        Returns
        -------
        Series

        Examples
        --------
        >>> import cudf
        >>> s = cudf.Series([12231312123, 1231231231, 1123236768712, 2135656,
        ...     3244334234], dtype='timedelta64[ms]')
        >>> s
        0      141 days 13:35:12.123
        1       14 days 06:00:31.231
        2    13000 days 10:12:48.712
        3        0 days 00:35:35.656
        4       37 days 13:12:14.234
        dtype: timedelta64[ms]
        >>> s.dt.days
        0      141
        1       14
        2    13000
        3        0
        4       37
        dtype: int64
        """
        return self._get_td_field("days")

    @property
    def seconds(self):
        """
        Number of seconds (>= 0 and less than 1 day).

        Returns
        -------
        Series

        Examples
        --------
        >>> import cudf
        >>> s = cudf.Series([12231312123, 1231231231, 1123236768712, 2135656,
        ...     3244334234], dtype='timedelta64[ms]')
        >>> s
        0      141 days 13:35:12.123
        1       14 days 06:00:31.231
        2    13000 days 10:12:48.712
        3        0 days 00:35:35.656
        4       37 days 13:12:14.234
        dtype: timedelta64[ms]
        >>> s.dt.seconds
        0    48912
        1    21631
        2    36768
        3     2135
        4    47534
        dtype: int64
        >>> s.dt.microseconds
        0    123000
        1    231000
        2    712000
        3    656000
        4    234000
        dtype: int64
        """
        return self._get_td_field("seconds")

    @property
    def microseconds(self):
        """
        Number of microseconds (>= 0 and less than 1 second).

        Returns
        -------
        Series

        Examples
        --------
        >>> import cudf
        >>> s = cudf.Series([12231312123, 1231231231, 1123236768712, 2135656,
        ...     3244334234], dtype='timedelta64[ms]')
        >>> s
        0      141 days 13:35:12.123
        1       14 days 06:00:31.231
        2    13000 days 10:12:48.712
        3        0 days 00:35:35.656
        4       37 days 13:12:14.234
        dtype: timedelta64[ms]
        >>> s.dt.microseconds
        0    123000
        1    231000
        2    712000
        3    656000
        4    234000
        dtype: int64
        """
        return self._get_td_field("microseconds")

    @property
    def nanoseconds(self):
        """
        Return the number of nanoseconds (n), where 0 <= n < 1 microsecond.

        Returns
        -------
        Series

        Examples
        --------
        >>> import cudf
        >>> s = cudf.Series([12231312123, 1231231231, 1123236768712, 2135656,
        ...     3244334234], dtype='timedelta64[ns]')
        >>> s
        0    00:00:12.231312123
        1    00:00:01.231231231
        2    00:18:43.236768712
        3    00:00:00.002135656
        4    00:00:03.244334234
        dtype: timedelta64[ns]
        >>> s.dt.nanoseconds
        0    123
        1    231
        2    712
        3    656
        4    234
        dtype: int64
        """
        return self._get_td_field("nanoseconds")

    @property
    def components(self):
        """
        Return a Dataframe of the components of the Timedeltas.

        Returns
        -------
        DataFrame

        Examples
        --------
        >>> s = cudf.Series([12231312123, 1231231231, 1123236768712, 2135656, 3244334234], dtype='timedelta64[ms]')
        >>> s
        0      141 days 13:35:12.123
        1       14 days 06:00:31.231
        2    13000 days 10:12:48.712
        3        0 days 00:35:35.656
        4       37 days 13:12:14.234
        dtype: timedelta64[ms]
        >>> s.dt.components
            days  hours  minutes  seconds  milliseconds  microseconds  nanoseconds
        0    141     13       35       12           123             0            0
        1     14      6        0       31           231             0            0
        2  13000     10       12       48           712             0            0
        3      0      0       35       35           656             0            0
        4     37     13       12       14           234             0            0
        """  # noqa: E501
        return self.series._column.components(index=self.series._index)

    def _get_td_field(self, field):
        out_column = getattr(self.series._column, field)
        return Series(
            data=out_column, index=self.series._index, name=self.series.name
        )


def _align_indices(series_list, how="outer", allow_non_unique=False):
    """
    Internal util to align the indices of a list of Series objects

    series_list : list of Series objects
    how : {"outer", "inner"}
        If "outer", the values of the resulting index are the
        unique values of the index obtained by concatenating
        the indices of all the series.
        If "inner", the values of the resulting index are
        the values common to the indices of all series.
    allow_non_unique : bool
        Whether or not to allow non-unique valued indices in the input
        series.
    """
    if len(series_list) <= 1:
        return series_list

    # check if all indices are the same
    head = series_list[0].index

    all_index_equal = True
    for sr in series_list[1:]:
        if not sr.index.equals(head):
            all_index_equal = False
            break

    # check if all names are the same
    all_names_equal = True
    for sr in series_list[1:]:
        if not sr.index.names == head.names:
            all_names_equal = False
    new_index_names = [None]
    if all_names_equal:
        new_index_names = head.names

    if all_index_equal:
        return series_list

    if how == "outer":
        combined_index = cudf.core.reshape.concat(
            [sr.index for sr in series_list]
        ).unique()
        combined_index.names = new_index_names
    else:
        combined_index = series_list[0].index
        for sr in series_list[1:]:
            combined_index = (
                cudf.DataFrame(index=sr.index).join(
                    cudf.DataFrame(index=combined_index),
                    sort=True,
                    how="inner",
                )
            ).index
        combined_index.names = new_index_names

    # align all Series to the combined index
    result = [
        sr._align_to_index(
            combined_index, how=how, allow_non_unique=allow_non_unique
        )
        for sr in series_list
    ]

    return result


def isclose(a, b, rtol=1e-05, atol=1e-08, equal_nan=False):
    """Returns a boolean array where two arrays are equal within a tolerance.

    Two values in ``a`` and ``b`` are  considiered equal when the following
    equation is satisfied.

    .. math::
       |a - b| \\le \\mathrm{atol} + \\mathrm{rtol} |b|

    Parameters
    ----------
    a : list-like, array-like or cudf.Series
        Input sequence to compare.
    b : list-like, array-like or cudf.Series
        Input sequence to compare.
    rtol : float
        The relative tolerance.
    atol : float
        The absolute tolerance.
    equal_nan : bool
        If ``True``, null's in ``a`` will be considered equal
        to null's in ``b``.

    Returns
    -------
    Series

    See Also
    --------
    np.isclose : Returns a boolean array where two arrays are element-wise
        equal within a tolerance.

    Examples
    --------
    >>> import cudf
    >>> s1 = cudf.Series([1.9876543,   2.9876654,   3.9876543, None, 9.9, 1.0])
    >>> s2 = cudf.Series([1.987654321, 2.987654321, 3.987654321, None, 19.9,
    ... None])
    >>> s1
    0    1.9876543
    1    2.9876654
    2    3.9876543
    3         <NA>
    4          9.9
    5          1.0
    dtype: float64
    >>> s2
    0    1.987654321
    1    2.987654321
    2    3.987654321
    3           <NA>
    4           19.9
    5           <NA>
    dtype: float64
    >>> cudf.isclose(s1, s2)
    0     True
    1     True
    2     True
    3    False
    4    False
    5    False
    dtype: bool
    >>> cudf.isclose(s1, s2, equal_nan=True)
    0     True
    1     True
    2     True
    3     True
    4    False
    5    False
    dtype: bool
    >>> cudf.isclose(s1, s2, equal_nan=False)
    0     True
    1     True
    2     True
    3    False
    4    False
    5    False
    dtype: bool
    """

    if not can_convert_to_column(a):
        raise TypeError(
            f"Parameter `a` is expected to be a "
            f"list-like or Series object, found:{type(a)}"
        )
    if not can_convert_to_column(b):
        raise TypeError(
            f"Parameter `b` is expected to be a "
            f"list-like or Series object, found:{type(a)}"
        )

    if isinstance(a, pd.Series):
        a = Series.from_pandas(a)
    if isinstance(b, pd.Series):
        b = Series.from_pandas(b)

    index = None

    if isinstance(a, cudf.Series) and isinstance(b, cudf.Series):
        b = b.reindex(a.index)
        index = as_index(a.index)

    a_col = column.as_column(a)
    a_array = cupy.asarray(a_col.data_array_view)

    b_col = column.as_column(b)
    b_array = cupy.asarray(b_col.data_array_view)

    result = cupy.isclose(
        a=a_array, b=b_array, rtol=rtol, atol=atol, equal_nan=equal_nan
    )
    result_col = column.as_column(result)

    if a_col.null_count and b_col.null_count:
        a_nulls = a_col.isna()
        b_nulls = b_col.isna()
        null_values = a_nulls | b_nulls

        if equal_nan is True:
            equal_nulls = a_nulls & b_nulls

        del a_nulls, b_nulls
    elif a_col.null_count:
        null_values = a_col.isna()
    elif b_col.null_count:
        null_values = b_col.isna()
    else:
        return Series(result_col, index=index)

    result_col[null_values] = False
    if equal_nan is True and a_col.null_count and b_col.null_count:
        result_col[equal_nulls] = True

    return Series(result_col, index=index)
