Python Pandas 的 Lambda 匿名函式:五個實用技巧

by 好豪

我曾在 另一篇文章 介紹過自己有多麽熱愛 Method Chaining 這項 Pandas 寫程式技巧,而實際上,要能釋放 Method Chaining 的強大威力,你不能不會 Lambda 匿名函式的運用方法!

在 Pandas 內活用 Lambda 匿名函式將會幫助你:

  • 寫出一連串流暢好讀而非支離破碎的專業程式碼
  • 減少儲存一大堆暫時的變數,也不用燒腦想這些變數名稱
  • 用函式邏輯高效工作省去多餘的打字時間

這篇文章將用 .assign().loc[]rename()apply()、以及 sort_values() 五個函式,各自用實例示範 Lambda 匿名函式的運用方法。本文每個分享案例都是我超愛用的技巧,建議讀者使用文章內的目錄功能,跳頁到你自己常用的那個函式開始閱讀,只要這篇文章的任何一項技巧能幫助到你提升寫程式效率,就是筆者撰文分享最大的成就感!

讀者也可以到我的 GitHub 找到本文的所有 Python Pandas 示範程式碼 Notebook,或者用 Google Colab 開啟,親自動手玩玩看。



什麼是 Lambda 函式

Lambda 函式 是只有一行運算式的 Python 函式(function),寫法很好懂:lambda 引數: 運算式

Lambda 有「匿名」的特色,你可以選擇不為函式取名字,所以 Lambda 被稱為匿名函式。然而,如果你不為函式取名,也代表你下次需要用到此函式的時候,根本找不到名稱可以再次呼叫它,因此,Lambda 通常適合此函式只會被使用一次的情境。

以下簡短比較一下 Lambda 函式跟一般的 def 函式寫法有什麼不同:

>>> def my_func_1(x):
...     return x * 100

>>> my_func_1(77)
7700

>>> my_func_2 = lambda x: x * 100

>>> my_func_2(77)
7700

在上方範例兩個函式做的事情一模一樣、只是語法不同,Lambda 雖然被稱為匿名函式,但實際上還是可以為函式取名字,如上方範例要用賦值(=)的方式。

以下範例是 Lambda 真正匿名的用法:

# 範例:把一個陣列內容全部乘以 100
>>> arr = list(range(1, 6))
>>> arr
[1, 2, 3, 4, 5]
>>> list(map(lambda x: x * 100, arr))
[100, 200, 300, 400, 500]

我們可以用兩大特色來記得 Lambda 函式:

  • 運算邏輯簡單時,它會幫助你少寫一點程式碼
  • 函式的功能一次性使用、不再覆用

兩項特色都超符合數據分析的寫程式工作流程,所以 Lambda 在 Pandas 裡非常好用!接下來我們來看看 Lambda 如何讓 Pandas 操作更加高效率。

範例資料表

開始之前,我們先看一眼本文用的範例資料表:seaborn 內建的鐵達尼號資料集,為求簡化,筆者只選了其中三個欄位當作示範:

  • who:”man”, “woman”, “child”
  • age: 年齡
  • survived: 是否獲救
import pandas as pd
import numpy as np
import seaborn as sns

data = sns.load_dataset('titanic')
data = data.loc[:, ['who', 'age', 'survived']]
>>> data.head()
     who   age  survived
0    man  22.0         0
1  woman  38.0         1
2  woman  26.0         1
3  woman  35.0         1
4    man  35.0         0

.assign()

.assign() 的功能是為你的資料表新增欄位。我在之前分享 Pandas Method Chaining 的文章曾介紹過,我認為 .assign() 是讓你 Pandas 程式碼更好讀、更有效率的必備函式,而要發揮 .assign() 的威力的話 Lambda 函式非用不可!

.assign() 中使用 Lambda 時,函式的引數輸入會是資料表、輸出必須是單維度資料(就是 Pandas 內的 Series,也可以視為陣列)。

我們直接用鐵達尼範例資料來學習,假設我們的任務如下:

請算出每個人是否高於所有人的平均年齡,而且需要完整計算流程

簡單!先算出所有人平均,再算出年齡是否大於平均就好。不使用 assign() 的話,程式碼會像這樣:

tmp = data.copy()
tmp['avg_age'] = tmp['age'].mean()
tmp['diff_from_avg_age'] = tmp['age'] - tmp['avg_age']
tmp['if_age_greater_than_avg'] = tmp['diff_from_avg_age'] > 0
>>> tmp.head()
     who   age  survived    avg_age  diff_from_avg_age  \
0    man  22.0         0  29.699118          -7.699118   
1  woman  38.0         1  29.699118           8.300882   
2  woman  26.0         1  29.699118          -3.699118   
3  woman  35.0         1  29.699118           5.300882   
4    man  35.0         0  29.699118           5.300882   

   if_age_greater_than_avg  
0                    False  
1                     True  
2                    False  
3                     True  
4                     True 

上方的程式碼確實完成任務了,但是有幾項值得挑剔的問題。首先,如果第一行忘記使用 .copy() 這個函式,你建立的暫時資料會連帶影響到原始資料(data)。還有,數數看 tmp 究竟被寫了多少次?如果我們為了任何理由需要更改暫時資料表的名稱(例如改成 new_data),那你需要自己動手替代掉程式碼的超多個 tmp 關鍵字!以上兩個問題,使用 Method Chaining 的 .assign() 結合接下來要介紹的 Lambda 函式應用,就可以解決。

此外,這裡還會遇到一個小小挑戰,每個計算流程的結果彼此相依,需要先做前一個步驟、才能進行下個步驟。例如,你必須先算出所有人的平均年齡(avg_age)、才算得出每個人年齡跟平均年齡差多少(diff_from_avg_age)。Pandas 裡面使用 Lambda 函數的最大好處解決了這項挑戰:Lambda 接受到的資料表引數包含了目前為止所有已更新的資料

new_data = (data
    .assign(
        avg_age = lambda df: df['age'].mean(),
        # 原本 `data` 裡面沒有 `avg_age` 這個欄位
        # 但是「此行以下」的所有 lambda 的引數 (df) 都會包含 `avg_age`
        diff_from_avg_age = lambda df: df['age'] - df['avg_age'],
        if_age_greater_than_avg = lambda df: df['diff_from_avg_age'] > 0
    )
)
>>> new_data.head()
     who   age  survived    avg_age  diff_from_avg_age  \
0    man  22.0         0  29.699118          -7.699118   
1  woman  38.0         1  29.699118           8.300882   
2  woman  26.0         1  29.699118          -3.699118   
3  woman  35.0         1  29.699118           5.300882   
4    man  35.0         0  29.699118           5.300882   

   if_age_greater_than_avg  
0                    False  
1                     True  
2                    False  
3                     True  
4                     True 

相較之下,如果你在使用 assign() 的過程不使用 Lambda,你就不能用到 Method Chaining 運算過程中的所有資料更新。例如,以下就是一個貌似合理、但實質上大錯特錯的寫法,每次 assign 新的欄位用到的 data 指向的都只是最原始的那個資料表,請讀者跑跑看以下這段程式碼,體會一下究竟沒使用到 Lambda 造成了什麼錯誤:

# 大錯特錯的寫法,會出現 Bug!
(data
    .assign(
        avg_age = data['age'].mean(),
        diff_from_avg_age = data['age'] - data['avg_age'],
        if_age_greater_than_avg = data['diff_from_avg_age'] > 0
    )
)

.loc[]

使用 .loc[] 可以為資料表篩選欄(Column)或列(Row)。

通常使用 .loc[] 時,我們會需要為欄跟列各自存下篩選條件的陣列、再放入 .loc[],寫法會是 .loc[列篩選條件陣列, 欄篩選條件陣列]

要改成在 .loc[] 裡使用 Lambda,函式的引數輸入會是資料表,列篩選條件的輸出必須是布林陣列或者索引(Index)、而欄篩選條件的輸出必須是欄位名稱的字串陣列。Lambda 函式回傳結果型態會像是:.loc[ [True, False, True, True], ['欄位_C', '欄位_K'] ]

直接從案例學習會更清楚,我們稍微修改在上方 .assign() 小節的任務來當作示範,比較使用 Lambda 與否的兩種作法:

請篩選出年齡高於平均的列資料,並且只留下年齡(age)相關的計算過程欄位

達成此任務基本的做法如下:

  1. 篩選年齡高於平均值的列
  2. 篩選名稱包含 age 的欄
  3. 前兩步驟結果全扔進 .loc[] 寫成 .loc[列篩選條件, 欄篩選條件]
# 篩選出年齡高於平均的那些人,並且只留下年齡(age)相關的計算過程欄位

## 照抄 .assign() 小節示範的寫法
tmp = (data
    .assign(
        avg_age = lambda df: df['age'].mean(),
        diff_from_avg_age = lambda df: df['age'] - df['avg_age'],
        if_age_greater_than_avg = lambda df: df['diff_from_avg_age'] > 0
    )
)

## 1. 篩選年齡高於平均值的 row
row_condition = tmp['if_age_greater_than_avg']

## 2. 篩選名稱包含 'age' 的 column
col_condition = [c for c in tmp.columns if c.find('age') > 0]

## 3. 把篩選條件都扔進 .loc[]
tmp.loc[row_condition, col_condition]
>>> tmp.loc[row_condition, col_condition].head()
      avg_age  diff_from_avg_age  if_age_greater_than_avg
1   29.699118           8.300882                     True
3   29.699118           5.300882                     True
4   29.699118           5.300882                     True
6   29.699118          24.300882                     True
11  29.699118          28.300882                     True

這種寫法的問題在於需要存很多暫時的變數(tmprow_conditioncol_condition),並且與 .assign() 小節的範例一樣,tmp 關鍵字被反覆寫了好多次。以下 .loc[] 用 Lambda 函式則得以一氣呵成:

# 在同一個 'Chain' 裡一次完成運算(.assign())與篩選(.loc[])
new_data = (data
    .assign(
        avg_age = lambda df: df['age'].mean(),
        diff_from_avg_age = lambda df: df['age'] - df['avg_age'],
        if_age_greater_than_avg = lambda df: df['diff_from_avg_age'] > 0
    )
    # 一行 .loc[] 同時完成 row 與 column 篩選!
    .loc[lambda df: df['if_age_greater_than_avg'], # row 篩選
         lambda df: [c for c in df.columns if c.find('age') > 0]] # column 篩選
)
>>> new_data.head()
      avg_age  diff_from_avg_age  if_age_greater_than_avg
1   29.699118           8.300882                     True
3   29.699118           5.300882                     True
4   29.699118           5.300882                     True
6   29.699118          24.300882                     True
11  29.699118          28.300882                     True

用 Lambda 改寫後,.assign() 運算過程、還有欄列篩選條件都不用存入一大堆其他變數,直接透過 Lambda 直接傳遞到 .loc[] 的引數中,這樣寫是不是更好讀、看起來更清爽流暢了呢!

在此請容我再囉唆一次:上方範例的 .loc[] 內容千萬不要用到 data 關鍵字寫成 .loc[data['if_age_greater_than_avg'], [c for c in data.columns if c.find('age') > 0]],我自己犯過超多次這種錯誤的,希望大家不要跟我一樣犯蠢。

.rename()

.rename() 會幫你重新命名資料表欄或列的名稱,但筆者好豪大多只會用到其 columns 引數的欄位重新命名的功能。

在欄位名稱有特定模式、然後我們需要依此特定模式更改欄位名稱的時候,在 .rename() 裡用 Lambda 超級方便,例如我們稍微修改後的以下資料:

new_data = (data
    .assign(
        if_cat_person=np.random.randint(2, size=data.shape[0])>0,
        if_dog_person=np.random.randint(2, size=data.shape[0])>0,
        if_allergy=np.random.randint(2, size=data.shape[0])>0,
        if_swimmer=np.random.randint(2, size=data.shape[0])>0,
        if_english_speaker=np.random.randint(2, size=data.shape[0])>0
    )
)
>>> new_data.head()
     who   age  survived  if_cat_person  if_dog_person  if_allergy  \
0    man  22.0         0           True           True        True   
1  woman  38.0         1           True           True       False   
2  woman  26.0         1          False           True        True   
3  woman  35.0         1          False           True        True   
4    man  35.0         0           True          False       False   

   if_swimmer  if_english_speaker  
0       False                True  
1        True               False  
2       False               False  
3        True               False  
4        True               False  

資料中,布林類型的資料全都是 if_ 開頭。然而,每個團隊的寫程式風格(Coding Style)都不同,Gitlab 的資料分析團隊 就要求布林類型資料的欄位名稱必須是 is_has_、或者 does_ 開頭,遇到這種情況,我們的任務是:

請把資料表中所有 if_ 開頭的布林資料欄位名稱,改成 is_ 開頭

首先,一般的 .rename() 作法,我們可以「手動」用 dict 資料結構,建立 {舊欄位名稱: 新欄位名稱} 這樣的對應關係,依此重新命名欄位:

(new_data
    .rename(columns={
        'if_cat_person': 'is_cat_person',
        'if_dog_person': 'is_dog_person',
        'if_allergy': 'is_allergy',
        'if_swimner': 'is_swimmer',
        'if_english_speaker': 'is_english_speaker'
    }) 
).head()
     who   age  survived  is_cat_person  is_dog_person  is_allergy  \
0    man  22.0         0           True          False        True   
1  woman  38.0         1          False          False       False   
2  woman  26.0         1           True           True       False   
3  woman  35.0         1          False          False        True   
4    man  35.0         0           True           True       False   

   if_swimmer  is_english_speaker  
0       False                True  
1        True                True  
2       False                True  
3       False               False  
4       False               False  

這種作法的缺點很明顯,要打超級多字的!並且超容易不小心粗心寫錯而沒注意到,其實上方範例有一個 if_ 開頭的欄位沒有成功重新命名喔,眼尖的讀者有發現是哪個欄位改名失敗、以及是哪裡打錯字導致改名失敗呢?

為了避免這種「手動」造成的錯誤(typo),Lambda 幫助我們用函式邏輯完成 rename() 任務。在 rename() 裡的 coloumns 引數,Lambda 函式的輸入是舊欄位名稱(字串),因此輸出也必須是字串以成為新欄位名稱。

# 'if_cat_person'.replace('if_', 'is_')  -> 輸出 'is_cat_person'

(new_data
    .rename(columns=lambda col: col.replace('if_', 'is_') 
                                if col.startswith('if_') else col) 
).head()
     who   age  survived  is_cat_person  is_dog_person  is_allergy  \
0    man  22.0         0           True          False        True   
1  woman  38.0         1          False          False       False   
2  woman  26.0         1           True           True       False   
3  woman  35.0         1          False          False        True   
4    man  35.0         0           True           True       False   

   is_swimmer  is_english_speaker  
0       False                True  
1        True                True  
2       False                True  
3       False               False  
4       False               False

用 Lambda 是不是比起自己一個個欄位手動打字改名簡便多了呢?而且就算新增了更多 if_ 開頭的欄位,同樣的 lambda 邏輯也能套用到新欄位、不用手動更新程式碼!



.apply()

.apply() 的功能與迴圈(Loop)相似,讓你沿著某個維度(Axis)重複執行某項工作,像是對每欄或每列執行相同動作。

然而,我自己在 Pandas 常用的其實是 .groupby().apply(),它們的工作流可以這樣想像:

  1. .groupby() 將資料表依據某(幾)個欄位的內容分組成多個資料表
  2. .apply() 對第一步拆出來的多個資料表各自執行同個動作

上述第二步的「同個動作」是我們可以用 Lambda 函式簡便定義的所在。在 .groupby() 後的 .apply() 內使用的 Lambda,函式的引數是分組後的資料表,輸出則沒有限制,下方第一個範例就是用 print() 而不回傳其他資料;後方也會示範有回傳數據的用法。

第一個 Lambda 函式簡單的示範,我們試著單純印出依據男性/女性/孩童三個類別 .groupby() 後的各個資料表:

>>> (data
...     .groupby('who')
...     .apply(lambda df: print(df.head(), '\n')) 
... )

      who   age  survived
7   child   2.0         0
9   child  14.0         1
10  child   4.0         1
14  child  14.0         0
16  child   2.0         0 

    who   age  survived
0   man  22.0         0
4   man  35.0         0
5   man   NaN         0
6   man  54.0         0
12  man  20.0         0 

      who   age  survived
1   woman  38.0         1
2   woman  26.0         1
3   woman  35.0         1
8   woman  27.0         1
11  woman  58.0         1 

繼續介紹更強的 .groupby() + .apply() + Lambda 應用之前,相信曾用過 .groupby() 的讀者,一定可以輕易完成分組並平均這項基本任務:

new_data = (data
    .groupby('who')
    ['survived']
    .mean()
)
>>> new_data.head()
who
child    0.590361
man      0.163873
woman    0.756458
Name: survived, dtype: float64

喔!男性成人在鐵達尼號的獲救率比其他兩個類別低好多,看到這項數據,筆者以後搭船可能會有陰影了 Q_Q

要是分組後要執行的動作比 .mean() 更複雜,就是 .apply() 加上 Lambda 發揮威力的時候了,例如:

請算出男性/女性/孩童這三組類別,低於各組平均年齡的人,平均獲救率為何?

new_data = (data
    .groupby('who')
    # 提醒:lambda 接受到的引數是「已分組」的資料表
    # 也就是 df 資料表只會包含 'child', 'man', 'woman' 其中一種
    .apply(lambda df: df.loc[df['age'] <= df['age'].mean(), 'survived'].mean())    
)
>>> new_data.head()
who
child    0.702128
man      0.165992
woman    0.738095
dtype: float64

跟上個範例比較後,會發現小孩子這個組別中,年齡較低的孩子獲救率比所有小孩子獲救率高了 10%,我們可以依此數據建立「小孩子年齡越低越容易獲救」的假設。

.sort_values()

.sort_values() 是讓資料排序的 Pandas 函式,除了基本的由小到大排列以外,排序的「原則」也是可以設定的!透過在 key 引數內加入 Lambda 函式來完成,Lambda 函式的引數會是來自排序目標的 Pandas 數列(Series)、輸出也必須是 Series

為了展示改變排序原則的技巧,這裡的範例筆者另外創了一個虛假資料,請特別注意第一個欄位資料結構是 dict

new_data = pd.DataFrame({
    'col_1': [{'c': 2}, {'a': 4}, {'d': 1}, {'b': 3}],
    'col_2': [7.25, 53.1, 13.0, 8.05]
})
>>> new_data.head()
      col_1  col_2
0  {'c': 2}   7.25
1  {'a': 4}  53.10
2  {'d': 1}  13.00
3  {'b': 3}   8.05

如果直接使用 col_1 來排序的話,Python 會告訴你 dict 不能排序、因此出現 Bug:

>>> new_data.sort_values('col_1')

... 其他錯誤訊息省略 ...
TypeError: '<' not supported between instances of 'dict' and 'dict'

這時 .sort_values()key 引數還有 Lambda 函式可以來拯救我們了,我們只要在 key 引數內寫清楚要對 col_1 實施什麼排序「原則」就能順利排序了,只需要注意 Lambda 的輸入與輸出都需要是 Series

# 用 dict 的 key 排序
>>> new_data.sort_values('col_1', 
...                      key=lambda s: s.map(lambda d: list(d.keys())[0]))
      col_1  col_2
1  {'a': 4}  53.10
3  {'b': 3}   8.05
0  {'c': 2}   7.25
2  {'d': 1}  13.00

# 用 dict 的 value 排序
>>> new_data.sort_values('col_1', 
...                      key=lambda s: s.map(lambda d: list(d.values())[0]))
      col_1  col_2
2  {'d': 1}  13.00
0  {'c': 2}   7.25
3  {'b': 3}   8.05
1  {'a': 4}  53.10

結語

Pandas 裡面可以用到 Lambda 匿名函式的場景還有很多,例如 groupby() 以及 pipe() 這些函數都可以接受 Lambda 作為引數。希望這篇文章分享的 .assign().loc[]rename()apply()、以及 sort_values() 五項技巧,能提升各位對 Lambda 函式的興趣、一起繼續探索 Lambda 在 Pandas 內能發揮的強大功能!

本文的所有 Python Pandas 示範程式碼都放在我的 GitHub,建議讀者 用 Google Colab 開啟,在雲端環境立刻開始練習這些程式碼。


這篇筆記來自於我在 《Pandas 資料清理、重塑、過濾、視覺化》 書中所學,此書作者示範了很多 Method Chaining 結合 Lambda 函式的 Pandas 程式寫法,程式碼風格清爽好讀,是我仍在不斷學習的模範寫法。我在這本書學會許多進階操作,因此推薦這本書給任何常用 Pandas 的人閱讀。

pandas-cookbook


你正在學習 Pandas 嗎?好豪還寫了其他 Pandas 學習心得與技巧分享,我相信會對你有幫助:

也歡迎追蹤好豪的 Facebook 粉絲專頁,我會持續分享 Pandas 以及 Python 的學習筆記;也可以點選下方按鈕,分享給對正在精進資料科學的朋友們。

推薦閱讀