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 pandas as pd
import numpy as np
[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 | 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 |
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 (-1.39, 0.332]
1 (-1.39, 0.332]
2 (-1.39, 0.332]
3 (-1.39, 0.332]
4 (-1.39, 0.332]
5 (-3.118, -1.39]
6 (-1.39, 0.332]
7 (0.332, 2.054]
8 (-1.39, 0.332]
9 (0.332, 2.054]
Name: data1, dtype: category
Categories (4, interval[float64, right]): [(-3.118, -1.39] < (-1.39, 0.332] < (0.332, 2.054] < (2.054, 3.775]]
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.39] | data1 | -3.111368 | -1.392580 | 87 | -1.824632 |
data2 | -3.298094 | 2.056192 | 87 | -0.058024 | |
(-1.39, 0.332] | data1 | -1.387235 | 0.329424 | 539 | -0.410836 |
data2 | -3.149511 | 3.125902 | 539 | 0.021746 | |
(0.332, 2.054] | data1 | 0.332701 | 1.986663 | 350 | 0.943457 |
data2 | -3.439061 | 3.333935 | 350 | -0.036310 | |
(2.054, 3.775] | data1 | 2.078016 | 3.775459 | 24 | 2.477261 |
data2 | -2.165898 | 1.447317 | 24 | 0.206394 |
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.111368 | -0.662711 | 250 | -1.282317 |
data2 | -3.298094 | 3.125902 | 250 | -0.040930 | |
1 | data1 | -0.661290 | 0.012193 | 250 | -0.328845 |
data2 | -3.149511 | 2.469953 | 250 | 0.079138 | |
2 | data1 | 0.014826 | 0.678196 | 250 | 0.344954 |
data2 | -3.439061 | 2.753681 | 250 | -0.064333 | |
3 | data1 | 0.680532 | 3.775459 | 250 | 1.304131 |
data2 | -2.551775 | 3.333935 | 250 | 0.021796 |
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.291512
2 -0.240357
3 NaN
4 -1.438728
5 0.846032
6 NaN
7 0.815120
dtype: float64
[13]:
s.fillna(s.mean())
[13]:
0 -0.061889
1 -0.291512
2 -0.240357
3 -0.061889
4 -1.438728
5 0.846032
6 -0.061889
7 0.815120
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 | ||
---|---|---|---|---|
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 | 3469.0 | 3469.0 | 3469.0 | |
Python Basics | de | 427.0 | 276.0 | 525.0 |
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 | ||
---|---|---|---|---|
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 | 3084.0 | 3971.0 | 3352.0 | |
Python Basics | de | 427.0 | 276.0 | 525.0 |
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 | 21168 | 0.603739 |
1 | de | 48307 | 0.670034 |
2 | de | 55202 | 0.509191 |
3 | de | 68992 | 0.970384 |
4 | en | 59026 | 0.758251 |
5 | en | 66174 | 0.601946 |
6 | en | 14267 | 0.206417 |
7 | en | 80557 | 0.751638 |
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 50921.420552
en 63877.585196
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)
3.97 ms ± 581 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
Wir können dasselbe Ergebnis auch ohne apply
erhalten indem wir unserer Methode top
den DataFrame übergeben:
[23]:
%%timeit
top(df)
223 µs ± 13.8 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
Diese Berechnung ist 18 mal schneller.
Optimieren von apply
mit Cython#
Nicht immer lässt sich jedoch für apply
so 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)
3.63 ms ± 128 µs per loop (mean ± std. dev. of 7 runs, 100 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.