提升 pandas 80% 效率秘訣大公開

提升 pandas 80% 效率秘訣大公開張憲騰BlockedUnblockFollowFollowingApr 18在上一篇文章:用記憶體講解 python list 為何比較慢我們知道了記憶體的觀念,也解釋了為何大量的數字運算盡量使用 numpy。但 numpy 也不見得那麼好用,且 python 的優勢無法在這個套件內體現,因此,偉大的工程師們寫出了一個基於 numpy 建構,又可以擁有好的資料處理特性的套件 — pandas。此時這已經解決我們大部分的效能問題,只是如果又遇到更大的資料,我們可以如何優化 Pandas呢?這邊你會看到Pandas 是如何運用 Numpy 提高效能的我們還能運用什麼方式幫助 Pandas 讓他跑得更快(如果不想看方法論可以直接滑到底看結論)ps.

底下方法論大部分從 Using pandas with Large Data Sets 而來Recap:Numpy 取得記憶體的方式讓程式跑更快,也更省記憶體Numpy 基建於記憶體上不同於 python,他是直接調用 C 語言的特性,先跟記憶體要一大塊空間,然後把資料存在這個空間內,我們用這張圖說明。圖片來源:Why Python is slow在 Python list 中,是透過取得一小部分記憶體儲存空陣列和指標( pointer ),而 Numpy 是直接將資料存入該記憶體,在存取時的差異讓 Numpy 在使用上彈性雖然較低,但卻擁有較高的效率但為了維持彈性,所以就有了基於 Numpy 建構的套件 Pandas,而 Pandas 是如何基於 Numpy 建置的呢?下面就開始進入正題。Pandas 內部有一個 Class : BlockManager,去管理不同型態的 Column,並將其分佈至不同的記憶體位置,讓 Numpy 使用起來更有彈性。這句話聽起來可能會有點拗口,大家可以看看這張圖片圖片來源:Using pandas with Large Data SetsblockManager 會針對每個 column 分類,根據型態將其儲存至不同的記憶體位置:例如上圖有 Int, Float, Object ( 在 numpy 內 string 代表是一個 object),每一個型態(type)都記錄在pandas.

core.

internals.

blocks 模組內,透過 blockManager 分離後,將其轉換成 numpy.

array 去儲存在不同的記憶體位置,如此一來,便可以在同一個資料集內放入不同型態的資料也可以兼顧產能了。由上述我們就可以解釋為何 pandas 是基於 numpy.

array 去建置,且也擁有簡易使用,快速清理資料的優點了。上面介紹了 pandas 是如何用 numpy.

array 去建置的,pandas 也是著名的記憶體怪獸,這邊我們還可以透過什麼方法讓 pandas 使用起來更聰明呢?這邊用一個專案來跟大家解釋:我們等等即將用棒球大聯盟 130年以來的資料來說明我們如何降低 pandas 使用的記憶體,在這邊你會用到:如何將 int64, float64 變成uint8, float32如何將 object 型態整合成 category而這兩個方法都可以使記憶體效率提高,下面也會實測給大家看喔!專案實測開始資料原始來源:Retrosheet統整資料:available here一開始我們先把資料 import 近來import pandas as pdgl = pd.

read_csv('game_logs.

csv')gl.

info(memory_usage='deep')預設 pandas 為了節省時間,只會算大概的記憶體使用程度,但我們是要比較精確的數字,所以我們將 memory_usage='deep'<class 'pandas.

core.

frame.

DataFrame'>RangeIndex: 171907 entries, 0 to 171906Columns: 161 entries, date to acquisition_infodtypes: float64(77), int64(6), object(78)memory usage: 861.

6 MB我們可以看到裡面總共有三個 types: float64, int64, object,總共 861.

6 MBfloat64 總共有 77 欄, int64 總共有 6 欄,而object 總共有 78 欄。看完整體的,我們來看看個別 Column 大概佔了多少記憶體空間吧:for dtype in ['float','int','object']: selected_dtype = gl.

select_dtypes(include=[dtype]) mean_usage_b = selected_dtype.

memory_usage(deep=True).

mean() mean_usage_mb = mean_usage_b / 1024 ** 2 print("Average memory usage for {} columns: {:03.

2f} MB".

format(dtype,mean_usage_mb))我們這邊用了 select_dtypes 這個 function,他會萃取裡面有對應type的資料(放在同一部分記憶體的)拿出來,供使用者使用。Average memory usage for float columns: 1.

29 MBAverage memory usage for int columns: 1.

12 MBAverage memory usage for object columns: 9.

53 MB這邊拿出來我們可以看到各自記憶體的使用狀況。那我們現在便一部份一部份來,我們先解決 數字系列 的欄位,在解決之前,先帶大家看看 subtypes 的觀念在 Pandas 內的資料型別有很多子型別,他們可以用較少的位元組去表示不同資料,比如,float 就有 float16, float32 和 float64 這些子型別。這些型別也分別會用不同的位元組 (bytes) 儲存,下圖列出了 pandas 中常用型別。int8 的型態使用了 1 bytes (或 8 bits) 來儲存值,而他可以代表 2⁸ (256)個二進位數字,這意味著我們可以用這種子型別去表示從 -128 ~127(包括0)的數值。我們可以使用 numpy.

iinfo 來看每一個子型態的最大值和最小值。import numpy as npint_types = ["uint8", "int8", "int16"]for it in int_types: print(np.

iinfo(it))下面是出來的數值。Machine parameters for uint8—————————————-min = 0max = 255Machine parameters for int8—————————————–min = -128max = 127Machine parameters for int16—————————————-min = -32768max = 32767我們可以看到如果欄位都是正數的話,使用 uint (unsigned int),可以儲存比較多的數值(0–255皆可)我們知道這件事後,這邊就要開始用 subtypes 優化數字欄位了。用 Subtypes 優化數字欄位我們可以使用 pd.

to_numric(),來對數值型態進行 downcast (向下型別轉換),例如:int64 -> int8 就是一種項下型別轉換。這邊我們只用 select_dtypes 這個方法來選擇整數列。def mem_usage(pandas_obj): if isinstance(pandas_obj,pd.

DataFrame): usage_b = pandas_obj.

memory_usage(deep=True).

sum() else: # we assume if not a df it's a series usage_b = pandas_obj.

memory_usage(deep=True) usage_mb = usage_b / 1024 ** 2 # convert bytes to megabytes return "{:03.

2f} MB".

format(usage_mb)上面先定義一個 function 讓我們知道記憶體的欄位使用多少gl_int = gl.

select_dtypes(include=['int'])converted_int = gl_int.

apply(pd.

to_numeric,downcast='unsigned')print("before: ", mem_usage(gl_int))print("after: ", mem_usage(converted_int))compare_ints = pd.

concat([gl_int.

dtypes,converted_int.

dtypes],axis=1)這邊來計算沒轉換過和轉換後的記憶體使用大小before: 7.

87 MBafter: 1.

48 MB我們只將 int64 轉換成 uint8,就讓記憶體節省了將近80% ( 7.

87 -> 1.

48),而我們可以看到原本 6 欄的 int64 也轉換成 5 欄的 uint8 和 1 欄的 uint32 ,當然這對整體來說只有一咪咪效果,因為 int 欄位在這個 case 太少了。接著我們也對 float 做相同的事情。gl_float = gl.

select_dtypes(include=['float'])converted_float = gl_float.

apply(pd.

to_numeric,downcast='float')print("before: ", mem_usage(gl_float))print("after: ", mem_usage(converted_float))compare_floats = pd.

concat([gl_float.

dtypes,converted_float.

dtypes],axis=1)這邊來計算沒轉換過和轉換後的記憶體使用大小before: 100.

99 MBafter: 50.

49 MB而 float 欄位,我們將 float64 全數轉換成 float32, 讓我們節省了 50%的空間,這時我們就可以來看看到底節省了多少optimized_gl = gl.

copy()optimized_gl[converted_int.

columns] = converted_intoptimized_gl[converted_float.

columns] = converted_floatprint("before: ", mem_usage(gl))print("after: ", mem_usage(optimized_gl))上面寫好 code 後就可以開始跑了before: 861.

57 MBafter: 804.

69 MB這邊節省了大概 50 幾MB (約莫 7 %),這不難想像,一些 primitive type 如 int, float, bool 本來就不會佔據太多記憶體,通常都是 string 這類的物件占據記憶體,所以我們現在就要來處理他啦。因為在 numpy 內部缺乏對 string 的支援,所以 string 通常會轉換成 object 的形式,這個限制導致了字串以碎片化的方式儲存,不僅消耗更多記憶體,並且訪問速度低下,為什麼呢?我們可以先來看看他的儲存方式,如下圖:圖片來源:Why Python is slow我們可以看到上圖在每個 object列中,都是儲存著指標 ( pointer ),他指向一堆碎片化的記憶體,其實就跟 用記憶體講解 python list 為何比較慢 的解釋一樣,這樣碎片化使用記憶體的方式,將會造成程式效率低下。那我們可以如何優化他呢?這時又要再介紹一個 Pandas 方法:Categoricals資料內部如果有很多重複資料,可以把它轉換成 Category 型態,讓他以 1,2,3…的分群方式儲存在記憶體內,這樣就可以釋出大量的記憶體空間增加使用效率,如下圖。圖片來源:Using pandas with Large Data Sets上面做了一個小結,大家可以看到如果重複資料轉換成 category,在 category 底層內會將重複資料以 int 的方式儲存,並把 dict來對應整數資料到原資料彼此的關係 ,類似如下:v_dict = { 0: "blue", 1: "red", 2: "yellow"}v_dict.

get(0) ## blue知道大概的原理後,我們就來實作吧!gl_obj = gl.

select_dtypes(include=['object']).

copy()gl_obj.

describe()首先我們先將儲存 object type的資料拉出來,看一下他每個欄位長怎樣,可以發現雖然資料有 17 萬個 row,但很多欄位以 unique 來說都只有少數幾個,這邊我們就可以用 category了。dow = gl_obj.

day_of_weekprint("original:.")print(dow.

head())print(".")dow_cat = dow.

astype('category')print("category:.")print(dow_cat.

head())這邊我們拿出 day_of_week那個欄位,然後給大家看將它變成 category 型態發生了什麼事。original:0 Thu1 Fri2 Sat3 Mon4 TueName: day_of_week, dtype: objectcategory:0 Thu1 Fri2 Sat3 Mon4 TueName: day_of_week, dtype: categoryCategories (7, object): [Fri, Mon, Sat, Sun, Thu, Tue, Wed] 轉換後來看看他們目前佔的記憶體空間print(mem_usage(dow)) # 9.

84 MBprint(mem_usage(dow_cat)) # 0.

16 MB這邊可以看到我們降低了 98 % 的記憶體空間,這將大量釋出了記憶體空間,讓程式可以運用靈活。但,是每個案例都要用 Category 的嗎?這邊幫各位舉出兩個不適合的案例:Category type 會讓內部喪失計算能力,也不能使用如 Series.

min() 或 Series.

max() 等方法唯一值數量 > 50% 不適合運用,因為這樣做不僅要儲存大部分的原始資料,還要儲存轉換後的 int type。所以下面我們將寫一個迴圈,對每一個 object欄位檢查是否唯一值 < 50%converted_obj = pd.

DataFrame()for col in gl_obj.

columns: num_unique_values = len(gl_obj[col].

unique()) num_total_values = len(gl_obj[col]) if num_unique_values / num_total_values < 0.

5: converted_obj.

loc[:,col] = gl_obj[col].

astype('category') else: converted_obj.

loc[:,col] = gl_obj[col]然後我們和之前一樣進行比較print(mem_usage(gl_obj)) # 752.

72 MBprint(mem_usage(converted_obj)) # 51.

67 MB我們可以發現整個 object 欄位從 752 MB -> 51 MB,整整 93% 的降幅,這是非常好的表現,接著我們再把 object 欄位跟剛剛優化的欄位合體optimized_gl[converted_obj.

columns] = converted_objmem_usage(optimized_gl) # 103.

64 MB我們已經將原來的 861 MB 的資料變成了 103.

64 MB,是 87% 的降幅,而這樣的降幅我們也只做將型態改變這些事情而已,如果各位在分析資料時都可以做這樣的前處理,我想整體速度上會快上許多喔。結論回應到我們這篇文的主軸:我們首先知道了 Pandas 和 Numpy 的合作機制是透過 BlockManager 這樣的 Class 來整合,並把不同的 type column 分佈在不同的記憶體位置。Pandas, Numpy 這類的套件為了讓使用起來更方便,所以預設在要記憶體時會先做最安全的措施:要最多的記憶體(為了預防記憶體不足),這也導致了使用起來相當笨拙而我們透過:將 int64, float64 變成uint8, float32將 object 型態整合成 category如此一來,便可以省下大量記憶體空間。謝謝大家的觀看,如果覺得這篇文章對您有幫助,也不忘幫我拍手喔,。請幫忙按 5 次 LikeButton 化讚為賞,回饋創作;如果登記 LikeCoin ID,登入後再按 LikeButton 我會得到更多獎賞,非常感謝!參考資料:用記憶體講解 python list 為何比較慢Using pandas with Large Data Setspandas source codeWhy Python is slow.

. More details

Leave a Reply