def cat_anon(c, prefix='', random_state=None):
    """
    Anonymise categories

    Neither the value nor the order of the categories is preserved.

    Parameters
    ----------
    c : list-like
        Values that will make up the categorical.

    random_state : int or ~numpy.random.RandomState, optional
        Seed or Random number generator to use. If ``None``, then
        numpy global generator :class:`numpy.random` is used.

    Returns
    -------
    out : categorical
        Values

    Examples
    --------
    >>> np.random.seed(123)
    >>> c = ['a', 'b', 'b', 'c', 'c', 'c']
    >>> cat_anon(c)
    ['0', '1', '1', '2', '2', '2']
    Categories (3, object): ['1', '0', '2']
    >>> cat_anon(c, 'c-', 321)
    ['c-1', 'c-2', 'c-2', 'c-0', 'c-0', 'c-0']
    Categories (3, object): ['c-0', 'c-2', 'c-1']
    >>> cat_anon(pd.Categorical(c, ordered=True), 'c-', 321)
    ['c-1', 'c-2', 'c-2', 'c-0', 'c-0', 'c-0']
    Categories (3, object): ['c-0' < 'c-2' < 'c-1']
    """
    c = as_categorical(c)
    if random_state is None:
        random_state = np.random
    elif isinstance(random_state, int):
        random_state = np.random.RandomState(random_state)
    elif not isinstance(random_state, np.random.RandomState):
        raise TypeError(
            "Unknown type `{}` of random_state".format(type(random_state))
        )

    # Shuffle two times,
    #   1. to prevent predicable sequence to category mapping
    #   2. to prevent reversing of the new categories to the old ones
    fmt = '{}{}'.format
    cats = [fmt(prefix, i) for i in range(len(c.categories))]
    random_state.shuffle(cats)
    c.rename_categories(cats, inplace=True)
    cats = c.categories.to_list()
    random_state.shuffle(cats)
    c.reorder_categories(cats, inplace=True)
    return c def cat_collapse(c, mapping, group_other=False):
    """
    Collapse categories into manually defined groups

    Parameters
    ----------
    c : list-like
        Values that will make up the categorical.
    mapping : dict
        New categories and the old categories contained in them.
    group_other : False
        If ``True``, a category is created to contain all other
        categories that have not been explicitly collapsed.
        The name of the other categories is ``other``, it may be
        postfixed by the first available integer starting from
        2 if there is a category with a similar name.

    Returns
    -------
    out : categorical
        Values

    Examples
    --------
    >>> c = ['a', 'b', 'c', 'd', 'e', 'f']
    >>> mapping = {'first_2': ['a', 'b'], 'second_2': ['c', 'd']}
    >>> cat_collapse(c, mapping)
    ['first_2', 'first_2', 'second_2', 'second_2', 'e', 'f']
    Categories (4, object): ['first_2', 'second_2', 'e', 'f']
    >>> cat_collapse(c, mapping, group_other=True)
    ['first_2', 'first_2', 'second_2', 'second_2', 'other', 'other']
    Categories (3, object): ['first_2', 'second_2', 'other']

    Collapsing preserves the order

    >>> cat_rev(c)
    ['a', 'b', 'c', 'd', 'e', 'f']
    Categories (6, object): ['f', 'e', 'd', 'c', 'b', 'a']
    >>> cat_collapse(cat_rev(c), mapping)
    ['first_2', 'first_2', 'second_2', 'second_2', 'e', 'f']
    Categories (4, object): ['f', 'e', 'second_2', 'first_2']
    >>> mapping = {'other': ['a', 'b'], 'another': ['c', 'd']}
    >>> cat_collapse(c, mapping, group_other=True)
    ['other', 'other', 'another', 'another', 'other2', 'other2']
    Categories (3, object): ['other', 'another', 'other2']
    """
    def make_other_name():
        """
        Generate unique name for the other category
        """
        if 'other' not in mapping:
            return 'other'

        for i in range(2, len(mapping)+2):
            other = 'other' + str(i)
            if other not in mapping:
                return other

    c = as_categorical(c)
    if group_other:
        mapping = mapping.copy()
        other = make_other_name()
        mapped_categories = list(chain(*mapping.values()))
        unmapped_categories = c.categories.difference(mapped_categories)
        mapping[other] = list(unmapped_categories)

    inverted_mapping = {
        cat: new_cat
        for new_cat, old_cats in mapping.items()
        for cat in old_cats
    }

    # Convert old categories to new values in order and remove
    # any duplicates. The preserves the order
    new_cats = pd.unique([
        inverted_mapping.get(x, x)
        for x in c.categories
    ])

    c = pd.Categorical(
        [inverted_mapping.get(x, x) for x in c],
        categories=new_cats,
        ordered=c.ordered
    )
    return c def cat_explicit_na(c, na_category='(missing)'):
    """
    Give missing values an explicity category

    Parameters
    ----------
    c : list-like
        Values that will make up the categorical.
    na_category : object (default: '(missing)')
        Category for missing values

    Examples
    --------
    >>> c = pd.Categorical(
    ...     ['a', 'b', None, 'c', None, 'd', 'd'],
    ...     ordered=True
    ... )
    >>> c
    ['a', 'b', NaN, 'c', NaN, 'd', 'd']
    Categories (4, object): ['a' < 'b' < 'c' < 'd']
    >>> cat_explicit_na(c)
    ['a', 'b', '(missing)', 'c', '(missing)', 'd', 'd']
    Categories (5, object): ['a' < 'b' < 'c' < 'd' < '(missing)']
    """
    c = as_categorical(c)
    bool_idx = pd.isnull(c)
    if any(bool_idx):
        c.add_categories([na_category], inplace=True)
        c[bool_idx] = na_category
    return c def cat_lump(
    c,
    n=None,
    prop=None,
    w=None,
    other_category='other',
    ties_method='min'
):
    """
    Lump together least or most common categories

    This is a general method that calls one of
    :func:`~plydata.cat_tools.cat_lump_n`
    :func:`~plydata.cat_tools.cat_lump_prop` or
    :func:`~plydata.cat_tools.cat_lump_lowfreq`
    depending on the parameters.

    Parameters
    ----------
    c : list-like
        Values that will make up the categorical.
    n : int (optional)
        Number of most/least common values to preserve (not lumped
        together). Positive ``n`` preserves the most common,
        negative ``n`` preserves the least common.

        Lumping happens on condition that the lumped category "other"
        will have the smallest number of items.
        You should only specify one of ``n`` or ``prop``
    prop : float (optional)
        Proportion above/below which the values of a category will be
        preserved (not lumped together). Positive ``prop`` preserves
        categories whose proportion of values is *more* than ``prop``.
        Negative ``prop`` preserves categories whose proportion of
        values is *less* than ``prop``.

        Lumping happens on condition that the lumped category "other"
        will have the smallest number of items.
        You should only specify one of ``n`` or ``prop``
    w : list[int|float] (optional)
        Weights for the frequency of each value. It should be the same
        length as ``c``.
    other_category : object (default: 'other')
        Value used for the 'other' values. It is placed at
        the end of the categories.
    ties_method : {'min', 'max', 'average', 'first', 'dense'} (default: min)
        How to treat categories that occur the same number of times
        (i.e. ties):
        * min: lowest rank in the group
        * max: highest rank in the group
        * average: average rank of the group
        * first: ranks assigned in order they appear in the array
        * dense: like 'min', but rank always increases by 1 between groups.

    Examples
    --------
    >>> cat_lump(list('abbccc'))
    ['other', 'b', 'b', 'c', 'c', 'c']
    Categories (3, object): ['b', 'c', 'other']

    When the least categories put together are not less than the next
    smallest group.

    >>> cat_lump(list('abcddd'))
    ['a', 'b', 'c', 'd', 'd', 'd']
    Categories (4, object): ['a', 'b', 'c', 'd']
    >>> cat_lump(list('abcdddd'))
    ['other', 'other', 'other', 'd', 'd', 'd', 'd']
    Categories (2, object): ['d', 'other']

    >>> c = pd.Categorical(list('abccdd'))
    >>> cat_lump(c, n=1)
    ['other', 'other', 'c', 'c', 'd', 'd']
    Categories (3, object): ['c', 'd', 'other']

    >>> cat_lump(c, n=2)
    ['other', 'other', 'c', 'c', 'd', 'd']
    Categories (3, object): ['c', 'd', 'other']

    ``n`` Least common categories

    >>> cat_lump(c, n=-2)
    ['a', 'b', 'other', 'other', 'other', 'other']
    Categories (3, object): ['a', 'b', 'other']

    There are fewer than ``n`` categories that are the most/least common.

    >>> cat_lump(c, n=3)
    ['a', 'b', 'c', 'c', 'd', 'd']
    Categories (4, object): ['a', 'b', 'c', 'd']
    >>> cat_lump(c, n=-3)
    ['a', 'b', 'c', 'c', 'd', 'd']
    Categories (4, object): ['a', 'b', 'c', 'd']

    By proportions, categories that make up *more* than ``prop`` fraction
    of the items.

    >>> cat_lump(c, prop=1/3.01)
    ['other', 'other', 'c', 'c', 'd', 'd']
    Categories (3, object): ['c', 'd', 'other']
    >>> cat_lump(c, prop=-1/3.01)
    ['a', 'b', 'other', 'other', 'other', 'other']
    Categories (3, object): ['a', 'b', 'other']
    >>> cat_lump(c, prop=1/2)
    ['other', 'other', 'other', 'other', 'other', 'other']
    Categories (1, object): ['other']

    Order of categoricals is maintained

    >>> c = pd.Categorical(
    ...     list('abccdd'),
    ...     categories=list('adcb'),
    ...     ordered=True
    ... )
    >>> cat_lump(c, n=2)
    ['other', 'other', 'c', 'c', 'd', 'd']
    Categories (3, object): ['d' < 'c' < 'other']

    **Weighted lumping**

    >>> c = list('abcd')
    >>> weights = [3, 2, 1, 1]
    >>> cat_lump(c, n=2)  # No lumping
    ['a', 'b', 'c', 'd']
    Categories (4, object): ['a', 'b', 'c', 'd']
    >>> cat_lump(c, n=2, w=weights)
    ['a', 'b', 'other', 'other']
    Categories (3, object): ['a', 'b', 'other']
    """
    if n is not None:
        return cat_lump_n(c, n, w, other_category, ties_method)
    elif prop is not None:
        return cat_lump_prop(c, prop, w, other_category)
    else:
        return cat_lump_lowfreq(c, other_category) def cat_lump_min(
    c,
    min,
    w=None,
    other_category='other',
):
    """
    Lump catogeries, preserving those that appear min number of times

    Parameters
    ----------
    c : list-like
        Values that will make up the categorical.
    min : int
        Minum number of times a category must be represented to be
        preserved.
    w : list[int|float] (optional)
        Weights for the frequency of each value. It should be the same
        length as ``c``.
    other_category : object (default: 'other')
        Value used for the 'other' values. It is placed at
        the end of the categories.

    Examples
    --------
    >>> c = list('abccdd')
    >>> cat_lump_min(c, min=1)
    ['a', 'b', 'c', 'c', 'd', 'd']
    Categories (4, object): ['a', 'b', 'c', 'd']
    >>> cat_lump_min(c, min=2)
    ['other', 'other', 'c', 'c', 'd', 'd']
    Categories (3, object): ['c', 'd', 'other']

    **Weighted Lumping**

    >>> weights = [2, 2, .5, .5, 1, 1]
    >>> cat_lump_min(c, min=2, w=weights)
    ['a', 'b', 'other', 'other', 'd', 'd']
    Categories (4, object): ['a', 'b', 'd', 'other']

    Unlike :func:`~plydata.cat_tools.cat_lump`,  :func:`cat_lump_min`
    can lump together and create a category larger than the preserved
    categories.

    >>> c = list('abxyzccdd')
    >>> cat_lump_min(c, min=2)
    ['other', 'other', 'other', 'other', 'other', 'c', 'c', 'd', 'd']
    Categories (3, object): ['c', 'd', 'other']
    """
    c = as_categorical(c)
    if len(c) == 0:
        return c

    if w is None:
        counts = c.value_counts().sort_values(ascending=False)
    else:
        counts = (
            pd.Series(w)
            .groupby(c)
            .apply(np.sum)
            .sort_values(ascending=False)
        )

    if (counts >= min).all():
        return c

    lookup = {
        cat: cat if freq >= min else other_category
        for cat, freq in counts.items()
    }
    new_cats = (
        c.categories
        .intersection(lookup.values())
        .insert(len(c), other_category)
    )

    c = pd.Categorical(
        [lookup[value] for value in c],
        categories=new_cats,
        ordered=c.ordered
    )
    return c def cat_other(c, keep=None, drop=None, other_category='other'):
    """
    Replace categories with 'other'

    Parameters
    ----------
    c : list-like
        Values that will make up the categorical.
    keep : list-like
        Categories to preserve. Only one of ``keep`` or ``drop``
        should be specified.
    drop : list-like
        Categories to drop. Only one of ``keep`` or ``drop``
        should be specified.
    other_category : object
        Value used for the 'other' values. It is placed at
        the end of the categories.

    Returns
    -------
    out : categorical
        Values

    Examples
    --------
    >>> c = ['a', 'b', 'a', 'c', 'b', 'b', 'b', 'd', 'c']
    >>> cat_other(c, keep=['a', 'b'])
    ['a', 'b', 'a', 'other', 'b', 'b', 'b', 'other', 'other']
    Categories (3, object): ['a', 'b', 'other']
    >>> cat_other(c, drop=['a', 'b'])
    ['other', 'other', 'other', 'c', 'other', 'other', 'other', 'd', 'c']
    Categories (3, object): ['c', 'd', 'other']
    >>> cat_other(pd.Categorical(c, ordered=True), drop=['a', 'b'])
    ['other', 'other', 'other', 'c', 'other', 'other', 'other', 'd', 'c']
    Categories (3, object): ['c' < 'd' < 'other']
    """
    if keep is None and drop is None:
        raise ValueError(
            "Missing columns to `keep` or those to `drop`."
        )
    elif keep is not None and drop is not None:
        raise ValueError(
            "Only one of `keep` or `drop` should be given."
        )
    c = as_categorical(c)
    cats = c.categories

    if keep is not None:
        if not pdtypes.is_list_like(keep):
            keep = [keep]
    elif drop is not None:
        if not pdtypes.is_list_like(drop):
            drop = [drop]
        keep = cats.difference(drop)

    inverted_mapping = {
        cat: other_category
        for cat in cats.difference(keep)
    }
    inverted_mapping.update({x: x for x in keep})
    new_cats = cats.intersection(keep).to_list() + [other_category]
    c = pd.Categorical(
        [inverted_mapping.get(x, x) for x in c],
        categories=new_cats,
        ordered=c.ordered
    )
    return c def cat_remove_unused(c, only=None):
    """
    Remove unused categories

    Parameters
    ----------
    c : list-like
        Values that will make up the categorical.
    only : list-like (optional)
        The categories to remove *if* they are empty. If not given,
        all unused categories are dropped.

    Examples
    --------
    >>> c = pd.Categorical(list('abcdd'), categories=list('bacdefg'))
    >>> c
    ['a', 'b', 'c', 'd', 'd']
    Categories (7, object): ['b', 'a', 'c', 'd', 'e', 'f', 'g']
    >>> cat_remove_unused(c)
    ['a', 'b', 'c', 'd', 'd']
    Categories (4, object): ['b', 'a', 'c', 'd']
    >>> cat_remove_unused(c, only=['a', 'e', 'g'])
    ['a', 'b', 'c', 'd', 'd']
    Categories (5, object): ['b', 'a', 'c', 'd', 'f']
    """
    if not pdtypes.is_categorical_dtype(c):
        # All categories are used
        c = pd.Categorical(c)
        return c
    else:
        c = c.copy()

    if only is None:
        only = c.categories

    used_idx = pd.unique(c.codes)
    used_categories = c.categories[used_idx]
    c = c.remove_categories(
        c.categories
        .difference(used_categories)
        .intersection(only)
    )
    return c def cat_rename(c, mapping=None, **kwargs):
    """
    Change/rename categories manually

    Parameters
    ----------
    c : list-like
        Values that will make up the categorical.
    mapping : dict (optional)
        Mapping of the form ``{old_name: new_name}`` for how to rename
        the categories. Setting a value to ``None`` removes the category.
        This arguments is useful if the old names are not valid
        python parameters. Otherwise, ``kwargs`` can be used.
    **kwargs : dict
        Mapping to rename categories. Setting a value to ``None`` removes
        the category.

    Examples
    --------
    >>> c = list('abcd')
    >>> cat_rename(c, a='A')
    ['A', 'b', 'c', 'd']
    Categories (4, object): ['A', 'b', 'c', 'd']
    >>> c = pd.Categorical(
    ...     list('abcd'),
    ...     categories=list('bacd'),
    ...     ordered=True
    ... )
    >>> cat_rename(c, b='B', d='D')
    ['a', 'B', 'c', 'D']
    Categories (4, object): ['B' < 'a' < 'c' < 'D']

    Remove categories by setting them to ``None``.

    >>> cat_rename(c, b='B', d=None)
    ['a', 'B', 'c']
    Categories (3, object): ['B' < 'a' < 'c']
    """
    c = as_categorical(c)
    if mapping is not None and len(kwargs):
        raise ValueError("Use only one of `new` or the ``kwargs``.")

    lookup = mapping or kwargs

    if not lookup:
        return c

    # Remove categories set to None
    remove = [
        old
        for old, new in lookup.items()
        if new is None
    ]
    if remove:
        for cat in remove:
            del lookup[cat]
        c = c.remove_categories(remove).dropna()

    # Separately change values (inplace) and the categories (using an
    # array) old to the new names. Then reconcile the two lists.
    categories = c.categories.to_numpy().copy()
    c.add_categories(
        pd.Index(lookup.values()).difference(c.categories),
        inplace=True
    )
    for old, new in lookup.items():
        if old not in c.categories:
            raise IndexError("Unknown category '{}'.".format(old))
        c[c == old] = new
        categories[categories == old] = new

    new_categories = pd.unique(categories)
    c.remove_unused_categories(inplace=True)
    c.set_categories(new_categories, inplace=True)
    return c def cat_reorder2(c, x, y, *args, fun=last2, ascending=False, **kwargs):
    """
    Reorder categorical by sorting along another variable

    It is the order of the categories that changes. Values in x
    are grouped by categories and summarised to determine the
    new order.

    Parameters
    ----------
    c : list-like
        Values that will make up the categorical.
    x : list-like
        Values by which ``c`` will be ordered.
    y : list-like
        Values by which ``c`` will be ordered.
    *args : tuple
        Position arguments passed to function fun.
    fun : callable
        Summarising function to ``x`` for each category in ``c``.
        Default is the *median*.
    ascending : bool
        If ``True``, the ``c`` is ordered in ascending order of ``x``.
    **kwargs : dict
        Keyword arguments passed to ``fun``.

    Examples
    --------

    Order stocks by the price in the latest year. This type of ordering
    can be used to order line plots so that the ends match the order of
    the legend.

    >>> stocks = list('AAABBBCCC')
    >>> year = [1980, 1990, 2000] * 3
    >>> price = [12.34, 12.90, 13.55, 10.92, 14.73, 11.08, 9.02, 12.44, 15.65]
    >>> cat_reorder2(stocks, year, price)
    ['A', 'A', 'A', 'B', 'B', 'B', 'C', 'C', 'C']
    Categories (3, object): ['C', 'A', 'B']
    """
    if len(c) != len(x) or len(x) != len(y):
        raise ValueError(
            "Lengths are not equal. len(c) is {}, len(x) is {} and "
            "len(y) is {}.".format(len(c), len(x), len(y))
        )

    # Wrap two argument function fun with a function that
    # takes a dataframe, put x and y into a dataframe, then
    # use dataframe.groupby
    def _fun(cat_df):
        return fun(cat_df['x'], cat_df['y'], *args, **kwargs)

    summary = (pd.DataFrame({'x': x, 'y': y})
               .groupby(c)
               .apply(_fun)
               .sort_values(ascending=ascending)
               )
    cats = summary.index.to_list()
    return pd.Categorical(c, categories=cats) def cat_shift(c, n=1):
    """
    Shift and wrap-around categories to the left or right

    Parameters
    ----------
    c : list-like
        Values that will make up the categorical.

    n : int
        Number of times to shift. If positive, shift to
        the left, if negative shift to the right.
        Default is 1.

    Returns
    -------
    out : categorical
        Values

    Examples
    --------
    >>> c = ['a', 'b', 'c', 'd', 'e']
    >>> cat_shift(c)
    ['a', 'b', 'c', 'd', 'e']
    Categories (5, object): ['b', 'c', 'd', 'e', 'a']
    >>> cat_shift(c, 2)
    ['a', 'b', 'c', 'd', 'e']
    Categories (5, object): ['c', 'd', 'e', 'a', 'b']
    >>> cat_shift(c, -2)
    ['a', 'b', 'c', 'd', 'e']
    Categories (5, object): ['d', 'e', 'a', 'b', 'c']
    >>> cat_shift(pd.Categorical(c, ordered=True), -3)
    ['a', 'b', 'c', 'd', 'e']
    Categories (5, object): ['c' < 'd' < 'e' < 'a' < 'b']
    """
    c = as_categorical(c)
    cats = c.categories.to_list()
    cats_extended = cats + cats
    m = len(cats)
    n = n % m
    cats = cats_extended[n:m] + cats_extended[:n]
    c.reorder_categories(cats, inplace=True)
    return c def cat_shuffle(c, random_state=None):
    """
    Reverse order of categories

    Parameters
    ----------
    c : list-like
        Values that will make up the categorical.
    random_state : int or ~numpy.random.RandomState, optional
        Seed or Random number generator to use. If ``None``, then
        numpy global generator :class:`numpy.random` is used.

    Returns
    -------
    out : categorical
        Values

    Examples
    --------
    >>> np.random.seed(123)
    >>> c = ['a', 'b', 'c', 'd', 'e']
    >>> cat_shuffle(c)
    ['a', 'b', 'c', 'd', 'e']
    Categories (5, object): ['b', 'd', 'e', 'a', 'c']
    >>> cat_shuffle(pd.Categorical(c, ordered=True), 321)
    ['a', 'b', 'c', 'd', 'e']
    Categories (5, object): ['d' < 'b' < 'a' < 'c' < 'e']
    """
    c = as_categorical(c)
    if random_state is None:
        random_state = np.random
    elif isinstance(random_state, int):
        random_state = np.random.RandomState(random_state)
    elif not isinstance(random_state, np.random.RandomState):
        raise TypeError(
            "Unknown type `{}` of random_state".format(type(random_state))
        )

    cats = c.categories.to_list()
    random_state.shuffle(cats)
    c.reorder_categories(cats, inplace=True)
    return c def cat_unify(cs, categories=None):
    """
    Unify (union of all) the categories in a list of categoricals

    Parameters
    ----------
    cs : list-like
        Categoricals
    categories : list-like
        Extra categories to apply to very categorical.

    Examples
    --------
    >>> c1 = pd.Categorical(['a', 'b'], categories=list('abc'))
    >>> c2 = pd.Categorical(['d', 'e'], categories=list('edf'))
    >>> c1_new, c2_new = cat_unify([c1, c2])
    >>> c1_new
    ['a', 'b']
    Categories (6, object): ['a', 'b', 'c', 'e', 'd', 'f']
    >>> c2_new
    ['d', 'e']
    Categories (6, object): ['a', 'b', 'c', 'e', 'd', 'f']
    >>> c1_new, c2_new = cat_unify([c1, c2], categories=['z', 'y'])
    >>> c1_new
    ['a', 'b']
    Categories (8, object): ['a', 'b', 'c', 'e', 'd', 'f', 'z', 'y']
    >>> c2_new
    ['d', 'e']
    Categories (8, object): ['a', 'b', 'c', 'e', 'd', 'f', 'z', 'y']
    """
    cs = [as_categorical(c) for c in cs]
    all_cats = list(chain(*(c.categories.to_list() for c in cs)))
    if categories is None:
        categories = pd.unique(all_cats)
    else:
        categories = pd.unique(all_cats + categories)

    cs = [c.set_categories(categories) for c in cs]
    return cs def cat_zip(*args, sep=':', keep_empty=False):
    """
    Create a new categorical (zip style) combined from two or more

    Parameters
    ----------
    *args : tuple
        Categoricals to be concatenated.
    sep : str (default: ':')
        Separator for the combined categories.
    keep_empty : bool (default: False)
        If ``True``, include all combinations of categories
        even those without observations.

    Examples
    --------
    >>> c1 = pd.Categorical(list('aba'))
    >>> c2 = pd.Categorical(list('122'))
    >>> cat_zip(c1, c2)
    ['a:1', 'b:2', 'a:2']
    Categories (3, object): ['a:1', 'a:2', 'b:2']
    >>> cat_zip(c1, c2, keep_empty=True)
    ['a:1', 'b:2', 'a:2']
    Categories (4, object): ['a:1', 'a:2', 'b:1', 'b:2']
    """
    values = [sep.join(items) for items in zip(*args)]
    cs = [
        c if pdtypes.is_categorical_dtype(c) else pd.Categorical(c)
        for c in args
    ]
    categories = [
        sep.join(items)
        for items in product(*(c.categories for c in cs))
    ]

    c = pd.Categorical(values, categories=categories)

    if not keep_empty:
        c.remove_unused_categories(inplace=True)

    return c
import pandas as pd
import pytest
from plydata.cat_tools import (
    cat_anon,
    cat_collapse,
    cat_explicit_na,
    cat_lump,
    cat_lump_min,
    cat_other,
    cat_remove_unused,
    cat_rename,
    cat_reorder2,
    cat_shift,
    cat_shuffle,
    cat_unify,
    cat_zip,
)
        cat_anon(c, random_state='bad_random_state')


def test_collapse():
    c = pd.Categorical(list('abcdef'), ordered=True)
    mapping = {'first_2': ['a', 'b'], 'second_2': ['e', 'd']}
    result = cat_collapse(c, mapping)
    expected_cats = pd.Index(['first_2', 'c', 'second_2', 'f'])
    assert result.ordered
    assert result.categories.equals(expected_cats)

    mapping = {'other': ['a', 'b'], 'other2': ['c', 'd']}
    result = cat_collapse(c, mapping, group_other=True)
    expected_cats = pd.Index(['other', 'other2', 'other3'])
    assert result.categories.equals(expected_cats)


def test_other():
    c = ['a', 'b', 'a', 'c', 'b', 'b', 'b', 'd', 'c']
    result = cat_other(c, drop='b')
    assert 'b' not in result

    result = cat_other(c, keep='b', other_category='others')
    assert 'b' in result
    assert 'others' in result
    assert 'a' not in result

    with pytest.raises(ValueError):
        cat_other(c, keep='a', drop='b')

    with pytest.raises(ValueError):
        cat_other(c)


def test_lump():
    result = cat_lump([])
    expected_cats = pd.Index([])
    assert result.categories.equals(expected_cats)

    result = cat_lump(['a'])
    expected_cats = pd.Index(['a'])
    assert result.categories.equals(expected_cats)

    result = cat_lump(['a'], n=1)
    expected_cats = pd.Index(['a'])
    assert result.categories.equals(expected_cats)

    result = cat_lump(['a'], prop=0.5)
    expected_cats = pd.Index(['a'])
    assert result.categories.equals(expected_cats)

    result = cat_lump(['a'], prop=-0.5)
    expected_cats = pd.Index(['other'])
    assert result.categories.equals(expected_cats)

    # No lumping since no category has is more than 0.5
    # (50%) of the values
    result = cat_lump(['a', 'b', 'c'], prop=-0.5)
    expected_cats = pd.Index(['a', 'b', 'c'])
    assert result.categories.equals(expected_cats)


def test_lump_min():
    result = cat_lump_min([], min=1)
    expected_cats = pd.Index([])
    assert result.categories.equals(expected_cats)

    c = pd.Categorical(list('abccdd'), categories=list('dcba'))
    result = cat_lump_min(c, min=2)
    expected_cats = pd.Index(['d', 'c', 'other'])
    assert result.categories.equals(expected_cats)


def test_rename():
    c = pd.Categorical([], categories=list('bacd'))
    result = cat_rename(c, b='B', d='D')
    expected_cats = pd.Index(list('BacD'))
    assert result.categories.equals(expected_cats)

    c = list('abcd')
    result = cat_rename(c)
    expected_cats = pd.Index(list('abcd'))
    assert result.categories.equals(expected_cats)

    with pytest.raises(ValueError):
        cat_rename(c, mapping={'a': 'A'}, b='B', d='D')

    with pytest.raises(IndexError):
        cat_rename(c, z='Z')


def test_explict_na():
    result = cat_explicit_na(['a', None, 'b'])
    expected_cats = pd.Index(['a', 'b', '(missing)'])
    assert result.categories.equals(expected_cats)


def test_drop():
    result = cat_remove_unused(list('abcd'))
    expected_cats = pd.Index(list('abcd'))
    assert result.categories.equals(expected_cats)


def test_unify():
    c1 = list('ab')
    c2 = list('dac')
    result = cat_unify([c1, c2], list('xw'))
    expected_cats = pd.Index(list('abcdxw'))
    assert result[0].categories.equals(expected_cats)
    assert result[1].categories.equals(expected_cats)


def test_zip():
    c1 = pd.Categorical(list('ab'), list('ba'))
    c2 = pd.Categorical(list('12'), list('12'))

    result = cat_zip(c1, c2, keep_empty=False)
    expected_cats = pd.Index(['b:2', 'a:1'])
    assert result.categories.equals(expected_cats)


    result = cat_zip(c1, c2, keep_empty=True)