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.