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

張憲騰
15 min readApr 19, 2019

--

在上一篇文章:用記憶體講解 python list 為何比較慢

我們知道了記憶體的觀念,也解釋了為何大量的數字運算盡量使用 numpy。

但 numpy 也不見得那麼好用,且 python 的優勢無法在這個套件內體現,因此,偉大的工程師們寫出了一個基於 numpy 建構,又可以擁有好的資料處理特性的套件 — pandas。

此時這已經解決我們大部分的效能問題,

只是如果又遇到更大的資料,我們可以如何優化 Pandas呢?

這邊你會看到

  1. Pandas 是如何運用 Numpy 提高效能的
  2. 我們還能運用什麼方式幫助 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 Sets

blockManager 會針對每個 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 pd
gl = 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 171906
Columns: 161 entries, date to acquisition_infodtypes: float64(77), int64(6), object(78)
memory usage: 861.6 MB

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

float64 總共有 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 MB
Average memory usage for int columns: 1.12 MB
Average 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 np
int_types = ["uint8", "int8", "int16"]
for it in int_types:
print(np.iinfo(it))

下面是出來的數值。

Machine parameters for uint8----------------------------------------
min = 0
max = 255
Machine parameters for int8-----------------------------------------
min = -128
max = 127
Machine parameters for int16----------------------------------------
min = -32768
max = 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 MB
after: 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 MB
after: 50.49 MB

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

optimized_gl = gl.copy()
optimized_gl[converted_int.columns] = converted_int
optimized_gl[converted_float.columns] = converted_float
print("before: ", mem_usage(gl))
print("after: ", mem_usage(optimized_gl))

上面寫好 code 後就可以開始跑了

before: 861.57 MB
after: 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:\n")
print(dow.head())
print("\n")
dow_cat = dow.astype('category')print("category:\n")
print(dow_cat.head())

這邊我們拿出 day_of_week那個欄位,然後給大家看將它變成 category 型態發生了什麼事。

original:
0 Thu
1 Fri
2 Sat
3 Mon
4 Tue
Name: day_of_week, dtype: object
category:
0 Thu
1 Fri
2 Sat
3 Mon
4 Tue
Name: day_of_week, dtype: category
Categories (7, object): [Fri, Mon, Sat, Sun, Thu, Tue, Wed]

轉換後來看看他們目前佔的記憶體空間

print(mem_usage(dow))         # 9.84 MB
print(mem_usage(dow_cat)) # 0.16 MB

這邊可以看到我們降低了 98 % 的記憶體空間,這將大量釋出了記憶體空間,讓程式可以運用靈活。

但,是每個案例都要用 Category 的嗎?這邊幫各位舉出兩個不適合的案例:

  1. Category type 會讓內部喪失計算能力,也不能使用如 Series.min()Series.max() 等方法
  2. 唯一值數量 > 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 MB
print(mem_usage(converted_obj)) # 51.67 MB

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

optimized_gl[converted_obj.columns] = converted_obj
mem_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 我會得到更多獎賞,非常感謝!

--

--