Apply

The most general GroupBy method is apply. It splits the object to be processed, calls the passed function on each part and then tries to chain the parts together.

Suppose we want to select the five largest hit values by group. To do this, we first write a function that selects the rows with the largest values in a particular column:

[1]:
import numpy as np
import pandas as pd
[2]:
df = pd.DataFrame(
    {
        "2021-12": [30134, 6073, 4873, None, 427, 95],
        "2022-01": [33295, 7716, 3930, None, 276, 226],
        "2022-02": [19651, 6547, 2573, None, 525, 157],
    },
    index=[
        [
            "Jupyter Tutorial",
            "Jupyter Tutorial",
            "PyViz Tutorial",
            "PyViz Tutorial",
            "Python Basics",
            "Python Basics",
        ],
        ["de", "en", "de", "en", "de", "en"],
    ],
)
df.index.names = ["Title", "Language"]

df
[2]:
2021-12 2022-01 2022-02
Title Language
Jupyter Tutorial de 30134.0 33295.0 19651.0
en 6073.0 7716.0 6547.0
PyViz Tutorial de 4873.0 3930.0 2573.0
en NaN NaN NaN
Python Basics de 427.0 276.0 525.0
en 95.0 226.0 157.0
[3]:
def top(df, n=5, column="2021-12"):
    return df.sort_values(by=column, ascending=False)[:n]


top(df, n=3)
[3]:
2021-12 2022-01 2022-02
Title Language
Jupyter Tutorial de 30134.0 33295.0 19651.0
en 6073.0 7716.0 6547.0
PyViz Tutorial de 4873.0 3930.0 2573.0

If we now group by titles, for example, and call apply with this function, we get the following:

[4]:
grouped_titles = df.groupby("Title", as_index=False)

grouped_titles.apply(top)
[4]:
2021-12 2022-01 2022-02
Title Language
0 Jupyter Tutorial de 30134.0 33295.0 19651.0
en 6073.0 7716.0 6547.0
1 PyViz Tutorial de 4873.0 3930.0 2573.0
en NaN NaN NaN
2 Python Basics de 427.0 276.0 525.0
en 95.0 226.0 157.0

What happened here? The upper function is called for each row group of the DataFrame, and then the results are concatenated with pandas.concat, labelling the parts with the group names. The result therefore has a hierarchical index whose inner level contains index values from the original DataFrame.

If you pass a function to apply that takes other arguments or keywords, you can pass them after the function:

[5]:
grouped_titles = df.groupby("Title", as_index=False)

grouped_titles.apply(top, n=1)
[5]:
2021-12 2022-01 2022-02
Title Language
0 Jupyter Tutorial de 30134.0 33295.0 19651.0
1 PyViz Tutorial de 4873.0 3930.0 2573.0
2 Python Basics de 427.0 276.0 525.0

We have now seen the basic usage of apply. What happens inside the passed function is very versatile and up to you; it only has to return a pandas object or a single value. In the following, we will therefore mainly show examples that can give you ideas on how to solve various problems with groupby.

First, let’s look again at describe, called over the GroupBy object:

[6]:
grouped_titles = df.groupby("Title")

result = grouped_titles.describe()

result
[6]:
2021-12 2022-01 2022-02
count mean std min 25% 50% 75% max count mean ... 75% max count mean std min 25% 50% 75% max
Title
Jupyter Tutorial 2.0 18103.5 17013.696262 6073.0 12088.25 18103.5 24118.75 30134.0 2.0 20505.5 ... 26900.25 33295.0 2.0 13099.0 9265.927261 6547.0 9823.0 13099.0 16375.0 19651.0
PyViz Tutorial 1.0 4873.0 NaN 4873.0 4873.00 4873.0 4873.00 4873.0 1.0 3930.0 ... 3930.00 3930.0 1.0 2573.0 NaN 2573.0 2573.0 2573.0 2573.0 2573.0
Python Basics 2.0 261.0 234.759451 95.0 178.00 261.0 344.00 427.0 2.0 251.0 ... 263.50 276.0 2.0 341.0 260.215295 157.0 249.0 341.0 433.0 525.0

3 rows × 24 columns

When you call a method like describe within GroupBy, it is actually just an abbreviation for:

[7]:
def desc(x):
    return x.describe()


grouped_titles.apply(desc)
[7]:
2021-12 2022-01 2022-02
Title
Jupyter Tutorial count 2.000000 2.000000 2.000000
mean 18103.500000 20505.500000 13099.000000
std 17013.696262 18087.084356 9265.927261
min 6073.000000 7716.000000 6547.000000
25% 12088.250000 14110.750000 9823.000000
50% 18103.500000 20505.500000 13099.000000
75% 24118.750000 26900.250000 16375.000000
max 30134.000000 33295.000000 19651.000000
PyViz Tutorial count 1.000000 1.000000 1.000000
mean 4873.000000 3930.000000 2573.000000
std NaN NaN NaN
min 4873.000000 3930.000000 2573.000000
25% 4873.000000 3930.000000 2573.000000
50% 4873.000000 3930.000000 2573.000000
75% 4873.000000 3930.000000 2573.000000
max 4873.000000 3930.000000 2573.000000
Python Basics count 2.000000 2.000000 2.000000
mean 261.000000 251.000000 341.000000
std 234.759451 35.355339 260.215295
min 95.000000 226.000000 157.000000
25% 178.000000 238.500000 249.000000
50% 261.000000 251.000000 341.000000
75% 344.000000 263.500000 433.000000
max 427.000000 276.000000 525.000000

Suppression of the group keys

In the previous examples, you saw that the resulting object has a hierarchical index formed by the group keys together with the indices of the individual parts of the original object. You can disable this by passing group_keys=False to groupby:

[8]:
grouped_lang = df.groupby("Language", group_keys=False)

grouped_lang.apply(top)
[8]:
2021-12 2022-01 2022-02
Title Language
Jupyter Tutorial de 30134.0 33295.0 19651.0
PyViz Tutorial de 4873.0 3930.0 2573.0
Python Basics de 427.0 276.0 525.0
Jupyter Tutorial en 6073.0 7716.0 6547.0
Python Basics en 95.0 226.0 157.0
PyViz Tutorial en NaN NaN NaN

Quantile and bucket analysis

As described in discretisation and grouping, pandas has some tools, especially cut and qcut, to split data into buckets with bins of your choice or by sample quantiles. Combine these functions with groupby and you can conveniently perform bucket or quantile analysis on a dataset. Consider a simple random data set and a bucket categorisation of equal length with cut:

[9]:
rng = np.random.default_rng()
df2 = pd.DataFrame(
    {
        "data1": rng.normal(size=1000),
        "data2": rng.normal(size=1000),
    },
)

quartiles = pd.cut(df2.data1, 4)

quartiles[:10]
[9]:
0    (-1.401, 0.235]
1    (-1.401, 0.235]
2    (-1.401, 0.235]
3    (-1.401, 0.235]
4    (-1.401, 0.235]
5     (0.235, 1.872]
6     (0.235, 1.872]
7     (0.235, 1.872]
8     (0.235, 1.872]
9    (-1.401, 0.235]
Name: data1, dtype: category
Categories (4, interval[float64, right]): [(-3.045, -1.401] < (-1.401, 0.235] < (0.235, 1.872] < (1.872, 3.509]]

The category object returned by cut can be passed directly to groupby. So we could calculate a set of group statistics for the quartiles as follows:

[10]:
def stats(group):
    return pd.DataFrame(
        {
            "min": group.min(),
            "max": group.max(),
            "count": group.count(),
            "mean": group.mean(),
        },
    )


grouped_quart = df2.groupby(quartiles, observed=False)

grouped_quart.apply(stats)
[10]:
min max count mean
data1
(-3.045, -1.401] data1 -3.038325 -1.403818 71 -1.777125
data2 -2.552644 1.823623 71 0.046782
(-1.401, 0.235] data1 -1.390785 0.234324 518 -0.472618
data2 -3.085377 3.088782 518 -0.003109
(0.235, 1.872] data1 0.236014 1.870917 386 0.831342
data2 -2.649750 3.874882 386 0.030917
(1.872, 3.509] data1 1.881248 3.509077 25 2.247147
data2 -1.899936 1.636602 25 0.107493

These were buckets of equal length; to calculate buckets of equal size based on sample quantiles, we can use qcut. I pass labels=False to get only quantile numbers:

[11]:
quartiles_samp = pd.qcut(df2.data1, 4, labels=False)

grouped_quart_samp = df2.groupby(quartiles_samp)

grouped_quart_samp.apply(stats)
[11]:
min max count mean
data1
0 data1 -3.038325 -0.687402 250 -1.205700
data2 -2.552644 2.762498 250 0.059576
1 data1 -0.684801 0.025629 250 -0.321806
data2 -3.085377 3.088782 250 -0.087235
2 data1 0.025695 0.655370 250 0.327563
data2 -2.649750 3.874882 250 0.127567
3 data1 0.659186 3.509077 250 1.224283
data2 -2.627217 2.615816 250 -0.034580

Populating data with group-specific values

When cleaning missing data, in some cases you will replace data observations with dropna, but in other cases you may want to fill the null values (NA) with a fixed value or a value derived from the data. fillna is the right tool for this; here, for example, I fill the null values with the mean:

[12]:
s = pd.Series(rng.normal(size=8))
s[::3] = np.nan

s
[12]:
0         NaN
1   -0.725159
2    0.297304
3         NaN
4    1.152454
5   -0.410714
6         NaN
7   -0.477529
dtype: float64
[13]:
s.fillna(s.mean())
[13]:
0   -0.032729
1   -0.725159
2    0.297304
3   -0.032729
4    1.152454
5   -0.410714
6   -0.032729
7   -0.477529
dtype: float64

Here are some sample data for my tutorials, divided into German and English editions:

Suppose you want the fill value to vary by group. These values can be predefined, and since the groups have an internal name attribute, you can use this with apply:

[14]:
fill_values = {"de": 10632, "en": 3469}


def fill(g):
    return g.fillna(fill_values[g.name])


df.groupby("Language").apply(fill)
[14]:
2021-12 2022-01 2022-02
Language Title Language
de Jupyter Tutorial de 30134.0 33295.0 19651.0
PyViz Tutorial de 4873.0 3930.0 2573.0
Python Basics de 427.0 276.0 525.0
en Jupyter Tutorial en 6073.0 7716.0 6547.0
PyViz Tutorial en 3469.0 3469.0 3469.0
Python Basics en 95.0 226.0 157.0

You can also group the data and use apply with a function that calls fillna for each data packet:

[15]:
def fill_mean(g):
    return g.fillna(g.mean())


df.groupby("Language").apply(fill_mean)
[15]:
2021-12 2022-01 2022-02
Language Title Language
de Jupyter Tutorial de 30134.0 33295.0 19651.0
PyViz Tutorial de 4873.0 3930.0 2573.0
Python Basics de 427.0 276.0 525.0
en Jupyter Tutorial en 6073.0 7716.0 6547.0
PyViz Tutorial en 3084.0 3971.0 3352.0
Python Basics en 95.0 226.0 157.0

Group weighted average

Since operations between columns in a DataFrame or two Series are possible, we can calculate the group-weighted average, for example:

[16]:
rng = np.random.default_rng()
df3 = pd.DataFrame(
    {
        "category": ["de", "de", "de", "de", "en", "en", "en", "en"],
        "data": rng.integers(100000, size=8),
        "weights": rng.random(8),
    },
)

df3
[16]:
category data weights
0 de 88218 0.414424
1 de 77067 0.035276
2 de 90737 0.769323
3 de 93228 0.384954
4 en 84643 0.241868
5 en 68233 0.063349
6 en 34558 0.354452
7 en 16885 0.986126

The group average weighted by category would then be:

[17]:
grouped_cat = df3.groupby("category")


def get_wavg(g):
    return np.average(g["data"], weights=g["weights"])


grouped_cat.apply(get_wavg, include_groups=False)
[17]:
category
de    90383.354698
en    32625.459028
dtype: float64

Correlation

An interesting task could be to calculate a DataFrame consisting of the percentage changes.

For this purpose, we first create a function that calculates the pairwise correlation of the 2021-12 column with the subsequent columns:

[18]:
def corr(x):
    return x.corrwith(x["2021-12"])

Next, we calculate the percentage change:

[19]:
pcts = df.pct_change().dropna()

pcts
/var/folders/hk/s8m0bblj0g10hw885gld52mc0000gn/T/ipykernel_71113/3358811060.py:1: FutureWarning: The default fill_method='pad' in DataFrame.pct_change is deprecated and will be removed in a future version. Either fill in any non-leading NA values prior to calling pct_change or specify 'fill_method=None' to not fill NA values.
  pcts = df.pct_change().dropna()
[19]:
2021-12 2022-01 2022-02
Title Language
Jupyter Tutorial en -0.798467 -0.768253 -0.666836
PyViz Tutorial de -0.197596 -0.490669 -0.606996
en 0.000000 0.000000 0.000000
Python Basics de -0.912374 -0.929771 -0.795958
en -0.777518 -0.181159 -0.700952

Finally, we group these percentage changes by year, which can be extracted from each row label with a one-line function that returns the year attribute of each date label:

[20]:
grouped_lang = pcts.groupby("Language")

grouped_lang.apply(corr)
[20]:
2021-12 2022-01 2022-02
Language
de 1.0 1.000000 1.00000
en 1.0 0.699088 0.99781
[21]:
grouped_lang.apply(lambda g: g["2021-12"].corr(g["2022-01"]))
[21]:
Language
de    1.000000
en    0.699088
dtype: float64

Performance problems with apply

Since the apply method typically acts on each individual value in a Series, the function is called once for each value. If you have thousands of values, the function will be called thousands of times. This ignores the fast vectorisations of pandas unless you are using NumPy functions and slow Python is used. For example, we previously grouped the data by title and then called our top method with apply. Let’s measure the time for this:

[22]:
%%timeit
grouped_titles.apply(top)
410 μs ± 6.29 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

We can get the same result without applying by passing the DataFrame to our top method:

[23]:
%%timeit
top(df)
38.7 μs ± 963 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

This calculation is 18 times faster.

Optimising apply with Cython

It is not always easy to find an alternative for apply. However, numerical operations like our top method can be made faster with Cython. To use Cython in Jupyyter, we use the following IPython magic:

[24]:
%load_ext Cython

Then we can define our top function with Cython:

[25]:
%%cython
def top_cy(df, n=5, column="2021-12"):
    return df.sort_values(by=column, ascending=False)[:n]
[26]:
%%timeit
grouped_titles.apply(top_cy)
422 μs ± 3.2 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

We haven’t really gained much with this yet. Further optimisation possibilities would be to define the type in the Cython code with cpdef. For this, however, we would have to modify our method, because then no DataFrame can be passed.