編譯 | AI科技大本營(rgznai100)
參與 | 周翔
注:Pandas(Python Data Analysis Library) 是基於 NumPy 的一種工具,該工具是為了解決資料分析任務而建立的。此外,Pandas 納入了大量庫和一些標準的資料模型,提供了高效地操作大型資料集所需的工具。
相比較於 Numpy,Pandas 使用一個二維的資料結構 DataFrame 來表示表格式的資料, 可以儲存混合的資料結構,同時使用 NaN 來表示缺失的資料,而不用像 Numpy 一樣要手工處理缺失的資料,並且 Pandas 使用軸標籤來表示行和列。
Pandas 通常用於處理小資料(小於 100Mb),而且對計算機的效能要求不高,但是當我們需要處理更大的資料時(100Mb到幾千Gb),計算機效能就成了問題,如果配置過低就會導致更長的執行時間,甚至因為記憶體不足導致執行失敗。
在處理大型資料集時(100Gb到幾TB),我們通常會使用像 Spark 這樣的工具,但是想要充分發揮 Spark 的功能,通常需要很高的硬體配置,導致成本過高。而且與 Pandas 不同,這些工具缺少可用於高質量資料清洗、勘測和分析的特徵集。
因此對於中等規模的資料,我們最好挖掘 Pandas 的潛能,而不是轉而使用其他工具。那麼在不升級計算機配置的前提下,我們要怎麼解決記憶體不足的問題呢?
在這篇文章中,我們將介紹 Pandas 的記憶體使用情況,以及如何通過為資料框(dataframe)中的列(column)選擇適當的資料型別,將資料框的記憶體佔用量減少近 90%。
我們將要處理的是 130 年來的大型棒球聯盟比賽資料,原始資料來源於 retrosheet。
最原始的資料是 127 個獨立的 CSV 檔案,不過我們已經使用 csvkit 合併了這些檔案,並且在第一行中為每一列新增了名字。
如果讀者想親自動手操作,可下載網站上的資料實踐下:https://data.world/dataquest/mlb-game-logs
首先讓我們匯入資料,看看前五行:
import pandas as pdgl = pd.read_csv('game_logs.csv')gl.head()
我們總結了一些重要的列,但是如果你想檢視所有的列的指南,我們也為整個資料集建立了一個資料字典:
我們可以使用 DataFrame.info() 的方法為我們提供資料框架的更多高層次的資訊,包括資料大小、型別、記憶體使用情況的資訊。預設情況下,Pandas 會佔用和資料框大小差不多的記憶體來節省時間。因為我們對準確度感興趣,所以我們將 memory_usage 的引數設定為 ‘deep’,以此來獲取更準確的數字。
我們可以看到,這個資料集共有 171,907 行、161 列。Pandas 已經自動檢測了資料的型別:83 列數字(numeric),78 列物件(object)。物件列(object columns)主要用於儲存字串,包含混合資料型別。為了更好地瞭解怎樣減少記憶體的使用量,讓我們看看 Pandas 是如何將資料儲存在記憶體中的。
在底層,Pandas 按照資料型別將列分成不同的塊(blocks)。這是 Pandas 如何儲存資料框前十二列的預覽。
你會注意到這些資料塊不會保留對列名的引用。這是因為資料塊對儲存資料框中的實際值進行了優化,BlockManager class 負責維護行、列索引與實際資料塊之間的對映。它像一個 API 來提供訪問底層資料的介面。每當我們選擇、編輯、或刪除某個值時,dataframe class 會和 BlockManager class 進行互動,將我們的請求轉換為函式和方法呼叫。
每個型別在 pandas.core.internals 模組中都有一個專門的類, Pandas 使用 ObjectBlock class 來代表包含字串列的塊,FloatBlock class 表示包含浮點型資料(float)列的塊。對於表示數值(如整數和浮點數)的塊,Pandas 將這些列組合在一起,並儲存為 NumPy ndarry 陣列。NumPy ndarry 是圍繞 C array 構建的,而且它們的值被儲存在連續的記憶體塊中。由於採用這種儲存方案,訪問這些值的地址片段(slice)是非常快的。
因為不同的資料都是單獨儲存的,所以我們將檢查不同型別的資料的記憶體使用情況。我們先來看看所有資料型別的平均記憶體使用情況。
可以看到,大部分的記憶體都被 78 個物件列佔用了。我們稍後再來分析,首先看看我們是否可以提高數字列(numeric columns)的記憶體使用率。
正如前面介紹的那樣,在底層,Pandas 將數值表示為 NumPy ndarrays,並將它儲存在連續的記憶體塊中。該儲存模型消耗的空間較小,並允許我們快速訪問這些值。因為 Pandas 中,相同型別的值會分配到相同的位元組數,而 NumPy ndarray 裡儲存了值的數量,所以 Pandas 可以快速並準確地返回一個數值列佔用的位元組數。
Pandas 中的許多型別包含了多個子型別,因此可以使用較少的位元組數來表示每個值。例如,float 型別就包含 float16、float32、float64 等子型別。型別名稱的數字部分代表了用於表示值型別的位數。例如,我們剛剛列出的子型別就分別使用了 2、4、8、16 個位元組。下表顯示了最常見的 Pandas 的子型別:
int8 使用 1 個位元組(或者 8 位)來儲存一個值,並且可以以二進位制表示 256 個值。這意味著,我們可以使用這種子型別來表示從 -128 到 127 (包括0)的值。我們可以使用 numpy.iinfo class 來驗證每個整數子型別的最小值和最大值,我們來看一個例子:
我們可以在這裡看到 uint(無符號整數)和 int(有符號整數)之間的區別。這兩種型別具有相同的儲存容量,但如果只儲存正數,無符號整數顯然能夠讓我們更高效地儲存只包含正值的列。
我們可以使用函式 pd.to_numeric() 來 downcast(向下轉型)我們的數值型別。我們將使用 DataFrame.select_dtypes 來選擇整數列,然後優化這些列包含的型別,並比較優化前後記憶體的使用情況。
我們可以看到,記憶體的使用量從 7.9Mb 降到了 1.5 Mb,減少了 80% 以上。但這對原始資料框的影響並不大,因為本身整數列就非常少。
現在,讓我們來對浮點型數列做同樣的事情。
可以看到,我們所有的浮點型數列都從 float64 轉換成 float32,使得記憶體的使用量減少了 50%。
讓我們建立一個原始資料框的副本,然後分配這些優化後的數字列代替原始資料,並檢視現在的記憶體使用情況。
雖然我們大大減少了數字列的記憶體使用量,但是從整體來看,我們只是將資料框的記憶體使用量降低了 7%。記憶體使用量降低的主要原因是我們對物件型別(object types)進行了優化。
在動手之前,讓我們仔細看一下,與數字型別相比,字串是怎樣存在 Pandas 中的。
物件型別代表了 Python 字串物件的值,部分原因是 NumPy 缺少對字串值的支援。因為 Python 是一種高階的解釋語言,它不能對數值的儲存方式進行細粒度控制。
這種限制使得字串以分散的方式儲存在記憶體裡,不僅佔用了更多的記憶體,而且訪問速度較慢。物件列表中的每一個元素都是一個指標(pointer),它包含了實際值在記憶體中位置的“地址”。
下面的圖示展示了數字值是如何儲存在 NumPy 資料型別中,以及字串如何使用 Python 內建的型別儲存。
你可能已經注意到,我們的圖表之前將物件型別描述成使用可變記憶體量。當每個指標佔用一位元組的記憶體時,每個字元的字串值佔用的記憶體量與 Python 中單獨儲存時相同。讓我們使用 sys.getsizeof() 來自證明這一點:先檢視單個字串,然後檢視 Pandas 系列中的專案(items)。
你可以看到,儲存在 Pandas 中的字串的大小與作為 Python 中單獨字串的大小相同。
Pandas 在 0.15版引入了 Categoricals (分類)。category 型別在底層使用整數型別來表示該列的值,而不是原始值。Pandas 用一個單獨的字典來對映整數值和相應的原始值之間的關係。當某一列包含的數值集有限時,這種設計是很有用的。當我們將列轉換為 category dtype 時,Pandas 使用了最省空間的 int 子型別,來表示一列中所有的唯一值。
想要知道我們可以怎樣使用這種型別來減少記憶體使用量。首先 ,讓我們看看每一種物件型別的唯一值的數量。
可以看到,我們的資料集中一共有 17.2 萬場比賽, 而唯一值的數量是非常少的。
在我們深入分析之前,我們首先選擇一個物件列,當我們將其轉換為 categorical type時,觀察下會發生什麼。我們選擇了資料集中的第二列 day_of_week 來進行試驗。
在上面的表格中,我們可以看到它只包含了七個唯一的值。我們將使用 .astype() 的方法將其轉換為 categorical。
如你所見,除了列的型別已經改變,這些資料看起來完全一樣。我們來看看發生了什麼。
在下面的程式碼中,我們使用 Series.cat.codes 屬性來返回 category 型別用來表示每個值的整數值。
你可以看到,每個唯一值都被分配了一個整數,並且該列的底層資料型別現在是 int8。該列沒有任何缺失值,如果有的話,這個 category 子型別會將預設值設定為 -1。最後,我們來看看這個列在轉換到 category 型別之前和之後的記憶體使用情況。
可以看到,記憶體使用量從原來的 9.8MB 降到了 0.16MB,相當於減少了 98%!請注意,這一列可能代表我們最好的情況之一:一個具有 172,000 個專案的列,只有 7 個唯一的值。
將所有的列都進行同樣的操作,這聽起來很吸引人,但使我們要注意權衡。可能出現的最大問題是無法進行數值計算。我們不能在將其轉換成真正的數字型別的前提下,對這些 category 列進行計算,或者使用類似 Series.min() 和 Series.max() 的方法。
當物件列中少於 50% 的值時唯一物件時,我們應該堅持使用 category 型別。但是如果這一列中所有的值都是唯一的,那麼 category 型別最終將佔用更多的記憶體。這是因為列不僅要儲存整數 category 程式碼,還要儲存所有的原始字串的值。你可以閱讀 Pandas 文件,瞭解 category 型別的更多限制。
我們將編寫一個迴圈程式,遍歷每個物件列,檢查其唯一值的數量是否小於 50%。如果是,那麼我們就將這一列轉換為 category 型別。
和之前的相比
在這種情況下,我們將所有物件列都轉換為 category 型別,但是這種情況並不符合所有的資料集,因此務必確保事先進行過檢查。
此外,物件列的記憶體使用量已經從 752MB 將至 52MB,減少了 93%。現在,我們將其與資料框的其餘部分結合起來,再與我們最開始的 861MB 的記憶體使用量進行對比。
可以看到,我們已經取得了一些進展,但是我們還有一個地方可以優化。回到我們的型別表,裡面有一個日期(datetime)型別可以用來表示資料集的第一列。
你可能記得這一列之前是作為整數型讀取的,而且已經被優化為 uint32。因此,將其轉換為 datetime 時,記憶體的佔用量會增加一倍,因為 datetime 的型別是 64 位。無論如何,將其轉換成 datetime 是有價值的,因為它將讓時間序列分析更加容易。
我們將使用 pandas.to_datetime() 函式進行轉換,並使用 format 引數讓日期資料按照 YYYY-MM-DD 的格式儲存。
到目前為止,我們已經探索了減少現有數據框記憶體佔用的方法。首先,讀入閱讀資料框,然後再反覆迭代節省記憶體的方法,這讓我們可以更好地瞭解每次優化可以節省的記憶體空間。然而,正如我們前面提到那樣,我們經常沒有足夠的記憶體來表示資料集中所有的值。如果一開始就不能建立資料框,那麼我們該怎樣使用記憶體節省技術呢?
幸運的是,當我們讀取資料集時,我們可以制定列的最優型別。pandas.read_csv() 函式有幾個不同的引數可以讓我們做到這一點。dtype 引數可以是一個以(字串)列名稱作為 keys、以 NumPy 型別物件作為值的字典。
首先,我們將每列的最終型別、以及列的名字的 keys 存在一個字典中。因為日期列需要單獨對待,因此我們先要刪除這一列。
現在,我們可以使用字典、以及幾個日期的引數,通過幾行程式碼,以正確的型別讀取日期資料。
通過優化這些列,我們設法將 pandas 中的記憶體使用量,從 861.6MB 降到了 104.28MB,減少了 88%。
我們已經優化了資料,現在我們可以開始對資料進行分析了。我們來看看比賽的時間分佈。
可以看到,在二十世紀二十年代之前,棒球比賽很少在週日舉行,一直到下半世紀才逐漸流行起來。此外,我們也可以清楚地看到,在過去的五十年裡,比賽時間的分是相對靜態的。我們來看看比賽時長多年來的變化。
看起來,棒球比賽的時長自 1940 年以來就一直處於增長狀態。
總結和後續步驟
我們已經瞭解到 Pandas 是如何儲存不同型別的資料的,然後我們使用這些知識將 Pandas 裡的資料框的記憶體使用量降低了近 90%,而這一切只需要幾個簡單的技巧:
你,學會了嗎?
原文地址
https://www.dataquest.io/blog/pandas-big-data/