Apply

Die am allgemeinsten einsetzbare GroupBy-Methode ist apply. Sie teilt das zu bearbeitende Objekt auf, ruft die übergebene Funktion auf jedem Teil auf und versucht dann, die Teile miteinander zu verketten.

Nehmen wir an, wir wollen die fünf größten hit-Werte nach Gruppen auswählen. Hierzu schreiben wir zunächst eine Funktion, die die Zeilen mit den größten Werten in einer bestimmten Spalte auswählt:

[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

Wenn wir nun z.B. nach Titeln gruppieren und apply mit dieser Funktion aufrufen, erhalten wir Folgendes:

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

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

Was ist hier passiert? Die obere Funktion wird für jede Zeilengruppe des DataFrame aufgerufen, und dann werden die Ergebnisse mit pandas.concat zusammengefügt, wobei die Teile mit den Gruppennamen gekennzeichnet werden. Das Ergebnis hat daher einen hierarchischen Index, dessen innere Ebene Indexwerte aus dem ursprünglichen DataFrame enthält.

Wenn ihr eine Funktion an apply übergebt, die andere Argumente oder Schlüsselwörter benötigt, könnt ihr diese nach der Funktion übergeben:

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

Wir haben nun die grundlegende Verwendungsweise von apply gesehen. Was innerhalb der übergebenen Funktion geschieht, ist sehr vielseitig und bleibt euch überlassen; sie muss nur ein pandas-Objekt oder einen Einzelwert zurückgeben. Im Folgend werden wir daher hauptsächlich Beispielen zeigen, die euch Anregungen geben können, wie ihr verschiedene Probleme mit groupby lösen könnt.

Zunächst vergegenwärtigen wir uns nochmal an describe, aufgerufen über dem GroupBy-Objekt:

[6]:
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

Wenn ihr innerhalb von GroupBy eine Methode wie describe aufruft, ist dies eigentlich nur eine Abkürzung für:

[7]:
f = lambda x: x.describe()

grouped_titles.apply(f)
[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

Unterdrückung der Gruppenschlüssel

In den vorangegangenen Beispielen habr ihr gesehen, dass das resultierende Objekt einen hierarchischen Index hat, der aus den Gruppenschlüsseln zusammen mit den Indizes der einzelnen Teile des ursprünglichen Objekts gebildet wird. Ihr können dies deaktivieren, indem ihr group_keys=False an groupby übergebt:

[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

Quantil- und Bucket-Analyse

Wie bereits in Diskretisierung und Gruppierung beschrieben, verfügt pandas über einige Werkzeuge, insbesondere cut und qcut, um Daten in Buckets mit Bins eurer Wahl oder nach Stichprobenquantilen aufzuteilen. Kombiniert man diese Funktionen mit groupby, kann man bequem eine Bucket- oder Quantilanalyse für einen Datensatz durchführen. Betrachtet einen einfachen Zufallsdatensatz und eine gleich lange Bucket-Kategorisierung mit cut:

[9]:
df2 = pd.DataFrame(
    {
        "data1": np.random.randn(1000),
        "data2": np.random.randn(1000)
    }
)
quartiles = pd.cut(df2.data1, 4)

quartiles[:10]
[9]:
0     (-0.0817, 1.433]
1     (-0.0817, 1.433]
2     (-0.0817, 1.433]
3    (-1.597, -0.0817]
4       (1.433, 2.948]
5     (-0.0817, 1.433]
6    (-1.597, -0.0817]
7     (-0.0817, 1.433]
8    (-1.597, -0.0817]
9    (-1.597, -0.0817]
Name: data1, dtype: category
Categories (4, interval[float64, right]): [(-3.118, -1.597] < (-1.597, -0.0817] < (-0.0817, 1.433] < (1.433, 2.948]]

Das von cut zurückgegebene Categorical-Objekt kann direkt an groupby übergeben werden. Wir könnten also eine Reihe von Gruppenstatistiken für die Quartile wie folgt berechnen:

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


grouped_quart = df2.groupby(quartiles)

grouped_quart.apply(stats)
[10]:
min max count mean
data1
(-3.118, -1.597] data1 -3.111451 -1.605312 53 -2.005040
data2 -2.032455 1.733728 53 -0.061672
(-1.597, -0.0817] data1 -1.589185 -0.081825 431 -0.714370
data2 -2.882329 3.902974 431 0.073316
(-0.0817, 1.433] data1 -0.077836 1.408519 441 0.554646
data2 -2.853656 2.558197 441 -0.068681
(1.433, 2.948] data1 1.442869 2.948015 75 1.882466
data2 -1.941170 3.242524 75 0.156225

Dies waren Buckets gleicher Länge; um Buckets gleicher Größe auf der Grundlage von Stichprobenquantilen zu berechnen, können wir qcut verwenden. Ich übergebe labels=False, um nur Quantilzahlen zu erhalten:

[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.111451 -0.726183 250 -1.287129
data2 -2.882329 2.629725 250 0.021474
1 data1 -0.726057 -0.013731 250 -0.372047
data2 -2.708554 3.902974 250 0.088167
2 data1 -0.013457 0.659397 250 0.276518
data2 -2.853656 2.180024 250 -0.091263
3 data1 0.660929 2.948015 250 1.269152
data2 -2.527610 3.242524 250 0.020658

Daten mit gruppenspezifischen Werten auffüllen

Wenn ihr fehlende Daten bereinigt, werdet ihr in einigen Fällen Datenbeobachtungen mit dropna ersetzen, aber in anderen Fällen möchtet ihr vielleicht die Nullwerte (NA) mit einem festen Wert oder einem aus den Daten abgeleiteten Wert auffüllen. fillna ist das richtige Werkzeug dafür; hier fülle ich zum Beispiel die Nullwerte mit dem Mittelwert auf:

[12]:
s = pd.Series(np.random.randn(8))
s[::3] = np.nan

s
[12]:
0         NaN
1   -0.004095
2   -0.525244
3         NaN
4    1.362079
5   -1.416516
6         NaN
7    0.891944
dtype: float64
[13]:
s.fillna(s.mean())
[13]:
0    0.061634
1   -0.004095
2   -0.525244
3    0.061634
4    1.362079
5   -1.416516
6    0.061634
7    0.891944
dtype: float64

Hier sind einige Beispieldaten zu meinen Tutorials, die in deutsch- und englischsprachige Ausgaben unterteilt sind:

Angenommen, ihr möchtet, dass der Füllwert je nach Gruppe variiert. Diese Werte können vordefiniert werden, und da die Gruppen ein internes Namensattribut name haben, könnt ihr dieses mit apply verwenden:

[14]:
fill_values = {"de": 10632, "en": 3469}
fill_func = lambda g: g.fillna(fill_values[g.name])

df.groupby("Language").apply(fill_func)
[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

Ihr könnt auch die Daten gruppieren und apply mit einer Funktion zu verwenden, die fillna für jedes Datenpaket aufruft:

[15]:
fill_mean = lambda g: 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

Gruppierter gewichteter Durchschnitt

Da Operationen zwischen Spalten in einem DataFrame oder zwei Series möglich sind, können wir z.B. den gruppengewichteten Durchschnitt berechnen:

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

df3
[16]:
category data weights
0 de 11386 0.748662
1 de 15461 0.524961
2 de 95386 0.460069
3 de 95307 0.067965
4 en 13249 0.646899
5 en 77389 0.313475
6 en 98805 0.651772
7 en 80871 0.782137

Der nach Kategorien gewichtete Gruppendurchschnitt würde dann lauten:

[17]:
grouped_cat = df3.groupby("category")
get_wavg = lambda g: np.average(g["data"], weights=g["weights"])

grouped_cat.apply(get_wavg)
[17]:
category
de    37189.285575
en    67026.662870
dtype: float64

Korrelation

Eine interessante Aufgabe könnte darin bestehen, einen DataFrame zu berechnen, der aus den prozentualen Veränderungen besteht.

Zu diesem Zweck erstellen wir zunächst eine Funktion, die die paarweise Korrelation der Spalte 2021-12 mit den nachfolgenden Spalten berechnet:

[18]:
corr = lambda x: x.corrwith(x["2021-12"])

Als nächstes berechnen wir die prozentuale Veränderung:

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

Schließlich gruppieren wir diese prozentualen Änderungen nach Jahr, das aus jeder Zeilenbeschriftung mit einer einzeiligen Funktion extrahiert werden kann, die das Attribut Jahr jeder Datumsbeschriftung zurückgibt:

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

by_language.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]:
by_language.apply(lambda g: g["2021-12"].corr(g["2022-01"]))
[21]:
Language
de    1.000000
en    0.699088
dtype: float64

Performance-Probleme mit apply

Da die apply-Methode typischerweise auf jeden einzelnen Wert in einer Series wirkt, wird die Funktion für jeden Wert einmal aufgerufen. Wenn ihr tausende Werte habt, wird die Funktion auch tausende Male aufgerufen. Dadurch werden die schnellen Vektorisierungen von pandas ignoriert sofern ihr keine NumPy-Funktionen verwendet, und langsames Python verwendet. Zum Beispiel haben wir zuvor die Daten nach Titel gruppiert und dann unsere top-Methode mit apply aufgerufen. Messen wir hierfür die Zeit:

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

Wir können dasselbe Ergebnis auch ohne apply erhalten indem wir unserer Methode top den DataFrame übergeben:

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

Diese Berechnung ist 18 mal schneller.

Optimieren von apply mit Cython

Nicht immer lässt sich jedoch für applyso einfach eine Alternative finden. Numerische Operationen wie unsere top-Methode lässt sich jedoch mit Cython schneller machen. Um Cython in Jupyyter zu nutzen, verwenden wir die folgende IPython-Magie:

[24]:
%load_ext Cython

Dann können wir unsere top-Funktion mit Cython definieren:

[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)
571 µs ± 4.62 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

Damit haben wir noch nicht wirklich viel gewonnen. Weitere Optimierungsmöglichkeiten wären nun, dass wir mit cpdef den Typ im Cython-Code definieren. Dafür müssten wir jedoch unsere Methode umbauen, da dann kein DataFrame mehr übergeben werden kann.