若是Pandas只是能把一些數據變成 dataframe
這樣優美的格式,那麼Pandas毫不會成爲叱吒風雲的數據分析中心組件。由於在數據分析過程當中,描述數據是經過一些列的統計指標實現的,分析結果也須要由具體的分組行爲,對各組橫向縱向對比。html
GroupBy
就是這樣的一個有力武器。事實上,SQL語言在Pandas出現的幾十年前就成爲了高級數據分析人員的標準工具,很大一部分緣由正是由於它有標準的SELECT xx FROM xx WHERE condition GROUP BY xx HAVING condition
範式。python
感謝 Wes Mckinney及其團隊,除了SQL以外,咱們多了一個更靈活、適應性更強的工具,而非困在SQL Shell或Python裏步履沉重。sql
SELECT Column1, Column2, mean(Column3), sum(Column4)
FROM SomeTable
WHERE Condition 1
GROUP BY Column1, Column2
HAVING Condition2數組
df [Condition1].groupby([Column1, Column2], as_index=False).agg({Column3: "mean", Column4: "sum"}).filter(Condition2)app
GroupBy能夠分解爲三個步驟:dom
那麼,這一套行雲流水的動做是如何完成的呢?ide
groupby
實現agg
、apply
、transform
、filter
實現具體的操做concat
等實現其中,在apply這一步,一般由如下四類操做:函數
注意,這裏討論的apply
,agg
,transform
,filter
方法都是限制在 pandas.core.groupby.DataFrameGroupBy
裏面,不能跟 pandas.core.groupby.DataFrame
混淆。工具
先導入須要用到的模塊大數據
import numpy as np import pandas as pd import sys, traceback from itertools import chain
df_0 = pd.DataFrame({'A': list(chain(*[['foo', 'bar']*4])), 'B': ['one', 'one', 'two', 'three', 'two', 'two', 'one', 'three'], 'C': np.random.randn(8), 'D': np.random.randn(8)})
df_0
A | B | C | D | |
---|---|---|---|---|
0 | foo | one | 1.145852 | 0.210586 |
1 | bar | one | -1.343518 | -2.064735 |
2 | foo | two | 0.544624 | 1.125505 |
3 | bar | three | 1.090288 | -0.296160 |
4 | foo | two | -1.854274 | 1.348597 |
5 | bar | two | -0.246072 | -0.598949 |
6 | foo | one | 0.348484 | 0.429300 |
7 | bar | three | 1.477379 | 0.917027 |
df_01 = df_0.copy() df_01.groupby(["A", "B"], as_index=False, sort=False).agg({"C": "sum", "D": "mean"})
A | B | C | D | |
---|---|---|---|---|
0 | foo | one | 1.494336 | 0.319943 |
1 | bar | one | -1.343518 | -2.064735 |
2 | foo | two | -1.309649 | 1.237051 |
3 | bar | three | 2.567667 | 0.310433 |
4 | bar | two | -0.246072 | -0.598949 |
df_02 = df_0.copy() df_02.groupby(["A", "B"]).agg({"C": "sum", "D": "mean"}).reset_index()
A | B | C | D | |
---|---|---|---|---|
0 | bar | one | -1.343518 | -2.064735 |
1 | bar | three | 2.567667 | 0.310433 |
2 | bar | two | -0.246072 | -0.598949 |
3 | foo | one | 1.494336 | 0.319943 |
4 | foo | two | -1.309649 | 1.237051 |
as_index=False
參數是一個好的習慣,由於若是dataframe很是巨大(好比達到GB以上規模)時,先生成一個Groupby對象,而後再調用reset_index()
會有額外的時間消耗。若是要獲得一個多層索引的數據框,使用默認的as_index=True
便可,例以下面的例子:
df_03 = df_0.copy() df_03.groupby(["A", "B"]).agg({"C": "sum", "D": "mean"})
C | D | ||
---|---|---|---|
A | B | ||
bar | one | -1.343518 | -2.064735 |
three | 2.567667 | 0.310433 | |
two | -0.246072 | -0.598949 | |
foo | one | 1.494336 | 0.319943 |
two | -1.309649 | 1.237051 |
注意,as_index
僅當作aggregation
操做時有效,若是是其餘操做,例如transform
,指定這個參數是無效的
df_04 = df_0.copy() df_04.groupby(["A", "B"], as_index=True).transform(lambda x: x * x)
C | D | |
---|---|---|
0 | 1.312976 | 0.044347 |
1 | 1.805040 | 4.263130 |
2 | 0.296616 | 1.266761 |
3 | 1.188727 | 0.087711 |
4 | 3.438331 | 1.818714 |
5 | 0.060552 | 0.358740 |
6 | 0.121441 | 0.184298 |
7 | 2.182650 | 0.840938 |
能夠看到,咱們獲得了一個和df_0同樣長度的新dataframe
,同時咱們還但願A,B能成爲索引,但這並無生效。
pd.Grouper
pd.Grouper
比 groupby
更強大、更靈活,它不只支持普通的分組,還支持按照時間進行升採樣或降採樣分組
df_1 = pd.read_excel("dataset\sample-salesv3.xlsx") df_1["date"] = pd.to_datetime(df_1["date"])
df_1.head()
account number | name | sku | quantity | unit price | ext price | date | |
---|---|---|---|---|---|---|---|
0 | 740150 | Barton LLC | B1-20000 | 39 | 86.69 | 3380.91 | 2014-01-01 07:21:51 |
1 | 714466 | Trantow-Barrows | S2-77896 | -1 | 63.16 | -63.16 | 2014-01-01 10:00:47 |
2 | 218895 | Kulas Inc | B1-69924 | 23 | 90.70 | 2086.10 | 2014-01-01 13:24:58 |
3 | 307599 | Kassulke, Ondricka and Metz | S1-65481 | 41 | 21.05 | 863.05 | 2014-01-01 15:05:22 |
4 | 412290 | Jerde-Hilpert | S2-34077 | 6 | 83.21 | 499.26 | 2014-01-01 23:26:55 |
【例子】計算每月的ext price總和
df_1.set_index("date").resample("M")["ext price"].sum()
date 2014-01-31 185361.66 2014-02-28 146211.62 2014-03-31 203921.38 2014-04-30 174574.11 2014-05-31 165418.55 2014-06-30 174089.33 2014-07-31 191662.11 2014-08-31 153778.59 2014-09-30 168443.17 2014-10-31 171495.32 2014-11-30 119961.22 2014-12-31 163867.26 Freq: M, Name: ext price, dtype: float64
df_1.groupby(pd.Grouper(key="date", freq="M"))["ext price"].sum()
date 2014-01-31 185361.66 2014-02-28 146211.62 2014-03-31 203921.38 2014-04-30 174574.11 2014-05-31 165418.55 2014-06-30 174089.33 2014-07-31 191662.11 2014-08-31 153778.59 2014-09-30 168443.17 2014-10-31 171495.32 2014-11-30 119961.22 2014-12-31 163867.26 Freq: M, Name: ext price, dtype: float64
兩種寫法都獲得了相同的結果,而且看上去第二種寫法彷佛有點兒難以理解。再看一個例子
【例子】計算每一個客戶每月的ext price總和
df_1.set_index("date").groupby("name")["ext price"].resample("M").sum().head(20)
name date Barton LLC 2014-01-31 6177.57 2014-02-28 12218.03 2014-03-31 3513.53 2014-04-30 11474.20 2014-05-31 10220.17 2014-06-30 10463.73 2014-07-31 6750.48 2014-08-31 17541.46 2014-09-30 14053.61 2014-10-31 9351.68 2014-11-30 4901.14 2014-12-31 2772.90 Cronin, Oberbrunner and Spencer 2014-01-31 1141.75 2014-02-28 13976.26 2014-03-31 11691.62 2014-04-30 3685.44 2014-05-31 6760.11 2014-06-30 5379.67 2014-07-31 6020.30 2014-08-31 5399.58 Name: ext price, dtype: float64
df_1.groupby(["name", pd.Grouper(key="date",freq="M")])["ext price"].sum().head(20)
name date Barton LLC 2014-01-31 6177.57 2014-02-28 12218.03 2014-03-31 3513.53 2014-04-30 11474.20 2014-05-31 10220.17 2014-06-30 10463.73 2014-07-31 6750.48 2014-08-31 17541.46 2014-09-30 14053.61 2014-10-31 9351.68 2014-11-30 4901.14 2014-12-31 2772.90 Cronin, Oberbrunner and Spencer 2014-01-31 1141.75 2014-02-28 13976.26 2014-03-31 11691.62 2014-04-30 3685.44 2014-05-31 6760.11 2014-06-30 5379.67 2014-07-31 6020.30 2014-08-31 5399.58 Name: ext price, dtype: float64
此次,第二種寫法遠比第一種寫法清爽、便於理解。這種按照特定字段和時間採樣的混合分組,請優先考慮用pd.Grouper
若是隻是作完拆分動做,沒有作後續的apply,獲得的是一個groupby
對象。這裏討論下如何訪問拆分出來的組
主要方法爲:
groups
get_group
df_2 = pd.DataFrame({'X': ['A', 'B', 'A', 'B'], 'Y': [1, 4, 3, 2]}) df_2
X | Y | |
---|---|---|
0 | A | 1 |
1 | B | 4 |
2 | A | 3 |
3 | B | 2 |
groups
方法能夠看到全部的組df_2.groupby("X").groups
{'A': Int64Index([0, 2], dtype='int64'), 'B': Int64Index([1, 3], dtype='int64')}
get_group
方法能夠訪問到指定的組df_2.groupby("X", as_index=True).get_group(name="A")
X | Y | |
---|---|---|
0 | A | 1 |
2 | A | 3 |
注意,get_group
方法中,name
參數只能傳遞單個str
,不能夠傳入list
,儘管Pandas中的其餘地方經常能看到這類傳參。若是是多列作主鍵的拆分,能夠傳入tuple
。
for name, group in df_2.groupby("X"): print(name) print(group,"\n")
A X Y 0 A 1 2 A 3 B X Y 1 B 4 3 B 2
這裏介紹一個小技巧,若是你獲得一個<pandas.core.groupby.groupby.DataFrameGroupBy object
對象,想要將它還原成其本來的 dataframe
,有一個很是簡便的方法值得一提:
gropbyed_object.apply(lambda x: x)
囿於篇幅,就不對API逐個解釋了,這裏僅指出最容易忽視也最容易出錯的三個參數
參數 | 注意事項 |
---|---|
level | 僅做用於層次化索引的數據框時有效 |
as_index | 僅對數據框作 agg 操做時有效, |
group_keys | 僅在調用 apply 時有效 |
拆分完成後,能夠對各個組作一些的操做,整體說來能夠分爲如下四類:
先總括地對比下這四類操做
Series
壓縮成一個標量值的都是agg
操做,例如求和、求均值、求極值等統計計算groupby
對象作變換,獲得子集或一個新的數據框的操做是apply
或transform
filter
apply
和 transform
有那麼一點類似,下文會重點剖析兩者
agg
和apply
均可以對特定列的數據傳入函數,而且依照函數進行計算。可是區別在於,agg
更加靈活高效,能夠一次完成操做。而apply
須要展轉屢次才能完成相同操做。
df_3 = pd.DataFrame({"name":["Foo", "Bar", "Foo", "Bar"], "score":[80,80,95,70]}) df_3
name | score | |
---|---|---|
0 | Foo | 80 |
1 | Bar | 80 |
2 | Foo | 95 |
3 | Bar | 70 |
咱們須要計算出每一個人的總分、最高分、最低分
(1)使用apply
方法
df_3.groupby("name", sort=False).score.apply(lambda x: x.sum())
name Foo 175 Bar 150 Name: score, dtype: int64
df_3.groupby("name", sort=False).score.apply(lambda x: x.max())
name Foo 95 Bar 80 Name: score, dtype: int64
df_3.groupby("name", sort=False).score.apply(lambda x: x.min())
name Foo 80 Bar 70 Name: score, dtype: int64
顯然,咱們展轉操做了3次,而且還須要額外一次操做(將所獲得的三個值粘合起來)
(2)使用agg
方法
df_3.groupby("name", sort=False).agg({"score": [np.sum, np.max, np.min]})
score | |||
---|---|---|---|
sum | amax | amin | |
name | |||
Foo | 175 | 95 | 80 |
Bar | 150 | 80 | 70 |
小結 agg
一次能夠對多個列獨立地調用不一樣的函數,而apply
一次只能對多個列調用相同的一個函數。
transform
做用於數據框自身,而且返回變換後的值。返回的對象和原對象擁有相同數目的行,但能夠擴展列。注意返回的對象不是就地修改了原對象,而是建立了一個新對象。也就是說原對象沒變。
df_4 = pd.DataFrame({'A': range(3), 'B': range(1, 4)}) df_4
A | B | |
---|---|---|
0 | 0 | 1 |
1 | 1 | 2 |
2 | 2 | 3 |
df_4.transform(lambda x: x + 1)
A | B | |
---|---|---|
0 | 1 | 2 |
1 | 2 | 3 |
2 | 3 | 4 |
能夠對數據框先分組,而後對各組賦予一個變換,例如元素自增1。下面這個例子意義不大,能夠直接作變換。
df_2.groupby("X").transform(lambda x: x + 1)
Y | |
---|---|
0 | 2 |
1 | 5 |
2 | 4 |
3 | 3 |
下面舉一個更實際的例子
df_5 = pd.read_csv(r"dataset\tips.csv") df_5.head()
total_bill | tip | sex | smoker | day | time | size | |
---|---|---|---|---|---|---|---|
0 | 16.99 | 1.01 | Female | No | Sun | Dinner | 2 |
1 | 10.34 | 1.66 | Male | No | Sun | Dinner | 3 |
2 | 21.01 | 3.50 | Male | No | Sun | Dinner | 3 |
3 | 23.68 | 3.31 | Male | No | Sun | Dinner | 2 |
4 | 24.59 | 3.61 | Female | No | Sun | Dinner | 4 |
如今咱們想知道天天,各數值列的均值
對比如下 agg
和 transform
兩種操做
df_5.groupby("day").aggregate("mean")
total_bill | tip | size | |
---|---|---|---|
day | |||
Fri | 17.151579 | 2.734737 | 2.105263 |
Sat | 20.441379 | 2.993103 | 2.517241 |
Sun | 21.410000 | 3.255132 | 2.842105 |
Thur | 17.682742 | 2.771452 | 2.451613 |
df_5.groupby('day').transform(lambda x : x.mean()).total_bill.unique()
array([21.41 , 20.44137931, 17.68274194, 17.15157895])
觀察得知,兩種操做是相同的,都是對各個小組求均值。所不一樣的是,agg
方法僅返回4行(即壓縮後的統計值),而transform
返回一個和原數據框一樣長度的新數據框。
transform
和 apply
的不一樣主要體如今兩方面:
apply
對於每一個組,都是同時在全部列上面調用函數;而 transform
是對每一個組,依次在每一列上調用函數apply
能夠返回標量、Series
、dataframe
——取決於你在什麼上面調用了apply
方法;而 transform
只能返回一個相似於數組的序列,例如一維的 Series
、array
、list
,而且最重要的是,要和原始組有一樣的長度,不然會引起錯誤。【例子】經過打印對象的類型來對比兩種方法的工做對象
df_6 = pd.DataFrame({'State':['Texas', 'Texas', 'Florida', 'Florida'], 'a':[4,5,1,3], 'b':[6,10,3,11]}) df_6
State | a | b | |
---|---|---|---|
0 | Texas | 4 | 6 |
1 | Texas | 5 | 10 |
2 | Florida | 1 | 3 |
3 | Florida | 3 | 11 |
def inspect(x): print(type(x)) print(x)
df_6.groupby("State").apply(inspect)
<class 'pandas.core.frame.DataFrame'> State a b 2 Florida 1 3 3 Florida 3 11 <class 'pandas.core.frame.DataFrame'> State a b 2 Florida 1 3 3 Florida 3 11 <class 'pandas.core.frame.DataFrame'> State a b 0 Texas 4 6 1 Texas 5 10
從打印結果咱們清晰地看到兩點:apply
每次做用的對象是一個 dataframe
,其次第一個組被計算了兩次,這是由於pandas
會經過這種機制來對比是否有更快的方式完成後面剩下組的計算。
df_6.groupby("State").transform(inspect)
<class 'pandas.core.series.Series'> 2 1 3 3 Name: a, dtype: int64 <class 'pandas.core.series.Series'> 2 3 3 11 Name: b, dtype: int64 <class 'pandas.core.frame.DataFrame'> a b 2 1 3 3 3 11 <class 'pandas.core.series.Series'> 0 4 1 5 Name: a, dtype: int64 <class 'pandas.core.series.Series'> 0 6 1 10 Name: b, dtype: int64
從打印結果咱們也清晰地看到兩點:transform
每次只計算一列;會出現計算了一個組總體的狀況,這有點使人費解,待研究。
從上面的對比,咱們直接獲得了一個有用的警示:不要傳一個同時涉及到多列的函數給transform
方法,由於那麼作只會獲得錯誤。例以下面的代碼所示:
def subtract(x): return x["a"] - x["b"]
try: df_6.groupby("State").transform(subtract) except Exception: exc_type, exc_value, exc_traceback = sys.exc_info() formatted_lines = traceback.format_exc().splitlines() print(formatted_lines[-1])
KeyError: ('a', 'occurred at index a')
另外一個警示則是:在使用 transform
方法的時候,不要去試圖修改返回結果的長度,那樣不只會引起錯誤,並且traceback的信息很是隱晦,極可能你須要花很長時間才能真正意識到錯誤所在。
def return_more(x): return np.arange(3)
try: df_6.groupby("State").transform(return_more) except Exception: exc_type, exc_value, exc_traceback = sys.exc_info() formatted_lines = traceback.format_exc().splitlines() print(formatted_lines[-1])
ValueError: Length mismatch: Expected axis has 6 elements, new values have 4 elements
這個報錯信息有點彆扭,期待返回6個元素,可是返回的結果只有4個元素;其實,應該說預期的返回爲4個元素,可是如今卻返回6個元素,這樣比較容易理解錯誤所在。
最後,讓咱們以一條有用的經驗結束這個talk:若是你確信本身想要的操做時同時做用於多列,而且速度最好還很快,請不要用transform
方法,Talk9
有一個這方面的好例子。
(1)一次對全部列調用多個函數
df_0.groupby("A").agg([np.sum, np.mean, np.min])
C | D | |||||
---|---|---|---|---|---|---|
sum | mean | amin | sum | mean | amin | |
A | ||||||
bar | 0.978077 | 0.244519 | -1.343518 | -2.042817 | -0.510704 | -2.064735 |
foo | 0.184686 | 0.046172 | -1.854274 | 3.113988 | 0.778497 | 0.210586 |
(2)一次對特定列調用多個函數
df_0.groupby("A")["C"].agg([np.sum, np.mean, np.min])
sum | mean | amin | |
---|---|---|---|
A | |||
bar | 0.978077 | 0.244519 | -1.343518 |
foo | 0.184686 | 0.046172 | -1.854274 |
(3)對不一樣列調用不一樣函數
df_0.groupby("A").agg({"C": [np.sum, np.mean], "D": [np.max, np.min]})
C | D | |||
---|---|---|---|---|
sum | mean | amax | amin | |
A | ||||
bar | 0.978077 | 0.244519 | 0.917027 | -2.064735 |
foo | 0.184686 | 0.046172 | 1.348597 | 0.210586 |
df_0.groupby("A").agg({"C": "sum", "D": "min"})
C | D | |
---|---|---|
A | ||
bar | 0.978077 | -2.064735 |
foo | 0.184686 | 0.210586 |
(4)對同一列調用不一樣函數,而且直接重命名
df_0.groupby("A")["C"].agg([("Largest", "max"), ("Smallest", "min")])
Largest | Smallest | |
---|---|---|
A | ||
bar | 1.477379 | -1.343518 |
foo | 1.145852 | -1.854274 |
(5)對多個列調用同一個函數
agg_keys = {}.fromkeys(["C", "D"], "sum") df_0.groupby("A").agg(agg_keys)
C | D | |
---|---|---|
A | ||
bar | 0.978077 | -2.042817 |
foo | 0.184686 | 3.113988 |
(6)注意agg
會忽略缺失值,這在計數時須要加以注意
df_7 = pd.DataFrame({"ID":["A","A","A","B","B"], "Num": [1,np.nan, 1,1,1]}) df_7
ID | Num | |
---|---|---|
0 | A | 1.0 |
1 | A | NaN |
2 | A | 1.0 |
3 | B | 1.0 |
4 | B | 1.0 |
df_7.groupby("ID").agg({"Num":"count"})
Num | |
---|---|
ID | |
A | 2 |
B | 2 |
注意:Pandas
中的 count
,sum
,mean
,median
,std
,var
,min
,max
等函數都用C語言優化過。因此,仍是那句話,若是你在大數據集上使用agg
,最好使用這些函數而非從numpy
那裏借用np.sum
等方法,一個緩慢的程序是由每一步的緩慢積累而成的。
一般,在對一個 dataframe
分組而且完成既定的操做以後,能夠直接返回結果,也能夠視需求對結果做一層過濾。這個過濾通常都是指 filter
操做,可是務必要理解清楚本身到底須要對組做過濾仍是對組內的每一行做過濾。這個Talk就來討論過濾這個話題。
【例子】找出每門課程考試分數低於這門課程平均分的學生
df_8 = pd.DataFrame({"Subject": list(chain(*[["Math"]*3,["Computer"]*3])), "Student": list(chain(*[["Chan", "Ida", "Ada"]*2])), "Score": [80,90,85,90,85,95]})
df_8
Subject | Student | Score | |
---|---|---|---|
0 | Math | Chan | 80 |
1 | Math | Ida | 90 |
2 | Math | Ada | 85 |
3 | Computer | Chan | 90 |
4 | Computer | Ida | 85 |
5 | Computer | Ada | 95 |
這樣一個需求是否適合用 filter
來處理呢?咱們試試看:
try: df_8.groupby("Subject").filter(lambda x: x["Score"] < x["Score"].mean()) except Exception: exc_type, exc_value, exc_traceback = sys.exc_info() formatted_lines = traceback.format_exc().splitlines() print(formatted_lines[-1])
TypeError: filter function returned a Series, but expected a scalar bool
顯然不行,由於 filter
實際上作的事情是要麼留下這個組,要麼過濾掉這個組。咱們在這裏弄混淆的東西,和咱們初學 SQL
時弄混 WHERE
和 HAVING
是一回事。就像須要記住 HAVING
是一個組內語法同樣,請記住 filter
是一個組內方法。
咱們先解決這個例子,正確的作法以下:
df_8.groupby("Subject").apply(lambda g: g[g.Score < g.Score.mean()])
Subject | Student | Score | ||
---|---|---|---|---|
Subject | ||||
Computer | 4 | Computer | Ida | 85 |
Math | 0 | Math | Chan | 80 |
而關於 filter
,咱們援引官方文檔上的例子做爲對比
df_9 = pd.DataFrame({'A' : ['foo', 'bar', 'foo', 'bar', 'foo', 'bar'], 'B' : [1, 2, 3, 4, 5, 6], 'C' : [2.0, 5., 8., 1., 2., 9.]}) df_9
A | B | C | |
---|---|---|---|
0 | foo | 1 | 2.0 |
1 | bar | 2 | 5.0 |
2 | foo | 3 | 8.0 |
3 | bar | 4 | 1.0 |
4 | foo | 5 | 2.0 |
5 | bar | 6 | 9.0 |
df_9.groupby('A').filter(lambda x: x['B'].mean() > 3.)
A | B | C | |
---|---|---|---|
1 | bar | 2 | 5.0 |
3 | bar | 4 | 1.0 |
5 | bar | 6 | 9.0 |
df_10 = pd.DataFrame({"ID":["A","A","A","B","B","B"], "Num": [100,np.nan,300,np.nan,500,600]}) df_10
ID | Num | |
---|---|---|
0 | A | 100.0 |
1 | A | NaN |
2 | A | 300.0 |
3 | B | NaN |
4 | B | 500.0 |
5 | B | 600.0 |
df_10.groupby("ID", as_index=False).Num.transform(lambda x: x.fillna(method="ffill")).transform(lambda x: x.fillna(method="bfill"))
Num | |
---|---|
0 | 100.0 |
1 | 100.0 |
2 | 300.0 |
3 | 500.0 |
4 | 500.0 |
5 | 600.0 |
若是dataframe
比較大(超過1GB),transform
+ lambda
方法會比較慢,能夠用下面這個方法,速度約比上面的組合快100倍。
df_10.groupby("ID",as_index=False).ffill().groupby("ID",as_index=False).bfill()
ID | Num | |
---|---|---|
0 | A | 100.0 |
1 | A | 100.0 |
2 | A | 300.0 |
3 | B | 500.0 |
4 | B | 500.0 |
5 | B | 600.0 |
參考資料:
https://stackoverflow.com/questions/44864655/pandas-difference-between-apply-and-aggregate-functions
https://stackoverflow.com/questions/27517425/apply-vs-transform-on-a-group-object
https://pandas.pydata.org/pandas-docs/stable/user_guide/groupby.html