我曾在 另一篇文章 介紹過自己有多麽熱愛 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)相關的計算過程欄位
達成此任務基本的做法如下:
- 篩選年齡高於平均值的列
- 篩選名稱包含
age
的欄 - 前兩步驟結果全扔進
.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
這種寫法的問題在於需要存很多暫時的變數(tmp
、row_condition
、col_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()
,它們的工作流可以這樣想像:
.groupby()
將資料表依據某(幾)個欄位的內容分組成多個資料表.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 嗎?好豪還寫了其他 Pandas 學習心得與技巧分享,我相信會對你有幫助:
- Python Pandas 的 Method Chaining 教學,讓資料分析程式碼變好讀
- Python Pandas 的長資料與寬資料轉換
- 案例分享:用 Python Enum 讓 Pandas 分析速度提升 5 倍
也歡迎追蹤好豪的 Facebook 粉絲專頁,我會持續分享 Pandas 以及 Python 的學習筆記;也可以點選下方按鈕,分享給對正在精進資料科學的朋友們。