天天看點

簡單又實用的pandas技巧:如何将記憶體占用降低90%

pandas 是一個 Python 軟體庫,可用于資料操作和分析。資料科學部落格 Dataquest.io 釋出了一篇關于如何優化 pandas 記憶體占用的教程:僅需進行簡單的資料類型轉換,就能夠将一個棒球比賽資料集的記憶體占用減少了近 90%,機器之心對本教程進行了編譯介紹。

當使用 pandas 操作小規模資料(低于 100 MB)時,性能一般不是問題。而當面對更大規模的資料(100 MB 到數 GB)時,性能問題會讓運作時間變得更漫長,而且會因為記憶體不足導緻運作完全失敗。

盡管 Spark 這樣的工具可以處理大型資料集(100 GB 到數 TB),但要完全利用它們的能力,往往需要更加昂貴的硬體。而且和 pandas 不同,它們缺少豐富的用于高品質資料清理、探索和分析的功能集。對于中等規模的資料,我們最好能更充分地利用 pandas,而不是換成另一種工具。

在這篇文章中,我們将了解 pandas 的記憶體使用,以及如何隻需通過為列選擇合适的資料類型就能将 dataframe 的記憶體占用減少近 90%。

簡單又實用的pandas技巧:如何将記憶體占用降低90%

處理棒球比賽日志

讓我們首先導入資料,并看看其中的前五行:

date - 比賽時間

v_name - 客隊名

v_league - 客隊聯盟

h_name - 主隊名

h_league - 主隊聯盟

v_score - 客隊得分

h_score - 主隊得分

v_line_score - 客隊每局得分排列,例如: 010000(10)00.

h_line_score - 主隊每局得分排列,例如: 010000(10)0X.

park_id - 比賽舉辦的球場名

attendance- 比賽觀衆

我們可以使用 DataFrame.info() 方法為我們提供關于 dataframe 的高層面資訊,包括它的大小、資料類型的資訊和記憶體使用情況。

預設情況下,pandas 會近似 dataframe 的記憶體用量以節省時間。因為我們也關心準确度,是以我們将 memory_usage 參數設定為 'deep',以便得到準确的數字。

我們可以看到,我們有 171,907 行和 161 列。pandas 會自動為我們檢測資料類型,發現其中有 83 列資料是數值,78 列是 object。object 是指有字元串或包含混合資料類型的情況。

為了更好地了解如何減少記憶體用量,讓我們看看 pandas 是如何将資料存儲在記憶體中的。

dataframe 的内部表示

在 pandas 内部,同樣資料類型的列會組織成同一個值塊(blocks of values)。這裡給出了一個示例,說明了 pandas 對我們的 dataframe 的前 12 列的存儲方式。

簡單又實用的pandas技巧:如何将記憶體占用降低90%

你可以看到這些塊并沒有保留原有的列名稱。這是因為這些塊為存儲 dataframe 中的實際值進行了優化。pandas 的 BlockManager 類則負責保留行列索引與實際塊之間的映射關系。它可以作為一個 API 使用,提供了對底層資料的通路。不管我們何時選擇、編輯或删除這些值,dataframe 類和 BlockManager 類的接口都會将我們的請求翻譯成函數和方法的調用。

在 pandas.core.internals 子產品中,每一種類型都有一個專門的類。pandas 使用 ObjectBlock 類來表示包含字元串列的塊,用 FloatBlock 類表示包含浮點數列的塊。對于表示整型數和浮點數這些數值的塊,pandas 會将這些列組合起來,存儲成 NumPy ndarray。NumPy ndarray 是圍繞 C 語言的數組建構的,其中的值存儲在記憶體的連續塊中。這種存儲方案使得對值的通路速度非常快。

因為每種資料類型都是分開存儲的,是以我們将檢查不同資料類型的記憶體使用情況。首先,我們先來看看各個資料類型的平均記憶體用量。

可以看出,78 個 object 列所使用的記憶體量最大。我們後面再具體談這個問題。首先我們看看能否改進數值列的記憶體用量。

了解子類型(subtype)

正如我們前面簡單提到的那樣,pandas 内部将數值表示為 NumPy ndarrays,并将它們存儲在記憶體的連續塊中。這種存儲模式占用的空間更少,而且也讓我們可以快速通路這些值。因為 pandas 表示同一類型的每個值時都使用同樣的位元組數,而 NumPy ndarray 可以存儲值的數量,是以 pandas 可以快速準确地傳回一個數值列所消耗的位元組數。

pandas 中的許多類型都有多個子類型,這些子類型可以使用更少的位元組來表示每個值。比如說 float 類型就包含 float16、float32 和 float64 子類型。類型名稱中的數字就代表該類型表示值的位(bit)數。比如說,我們剛剛列出的子類型就分别使用了 2、4、8、16 個位元組。下面的表格給出了 pandas 中最常用類型的子類型:

簡單又實用的pandas技巧:如何将記憶體占用降低90%

一個 int8 類型的值使用 1 個位元組的存儲空間,可以表示 256(2^8)個二進制數。這意味着我們可以使用這個子類型來表示從 -128 到 127(包括 0)的所有整數值。

我們可以使用 numpy.iinfo 類來驗證每個整型數子類型的最大值和最小值。舉個例子:

這裡我們可以看到 uint(無符号整型)和 int(有符号整型)之間的差異。這兩種類型都有一樣的存儲能力,但其中一個隻儲存 0 和正數。無符号整型讓我們可以更有效地處理隻有正數值的列。

使用子類型優化數值列

我們可以使用函數 pd.to_numeric() 來對我們的數值類型進行 downcast(向下轉型)操作。我們會使用 DataFrame.select_dtypes 來選擇整型列,然後我們會對其資料類型進行優化,并比較記憶體用量。

簡單又實用的pandas技巧:如何将記憶體占用降低90%

我們可以看到記憶體用量從 7.9 MB 下降到了 1.5 MB,降低了 80% 以上。但這對我們原有 dataframe 的影響并不大,因為其中的整型列非常少。

讓我們對其中的浮點型列進行一樣的操作。

簡單又實用的pandas技巧:如何将記憶體占用降低90%

我們可以看到浮點型列的資料類型從 float64 變成了 float32,讓記憶體用量降低了 50%。

讓我們為原始 dataframe 建立一個副本,并用這些優化後的列替換原來的列,然後看看我們現在的整體記憶體用量。

盡管我們極大地減少了數值列的記憶體用量,但整體的記憶體用量僅減少了 7%。我們的大部分收獲都将來自對 object 類型的優化。

在我們開始行動之前,先看看 pandas 中字元串的存儲方式與數值類型的存儲方式的比較。

數值存儲與字元串存儲的比較

object 類型表示使用 Python 字元串對象的值,部分原因是 NumPy 不支援缺失(missing)字元串類型。因為 Python 是一種進階的解釋性語言,它對記憶體中存儲的值沒有細粒度的控制能力。

這一限制導緻字元串的存儲方式很碎片化,進而會消耗更多記憶體,而且通路速度也更慢。object 列中的每個元素實際上都是一個指針,包含了實際值在記憶體中的位置的「位址」。

下面這幅圖給出了以 NumPy 資料類型存儲數值資料和使用 Python 内置類型存儲字元串資料的方式。

簡單又實用的pandas技巧:如何将記憶體占用降低90%

在前面的表格中,你可能已經注意到 object 類型的記憶體使用是可變的。盡管每個指針僅占用 1 位元組的記憶體,但如果每個字元串在 Python 中都是單獨存儲的,那就會占用實際字元串那麼大的空間。我們可以使用 sys.getsizeof() 函數來證明這一點,首先檢視單個的字元串,然後檢視 pandas series 中的項。

你可以看到,當存儲在 pandas series 時,字元串的大小與用 Python 單獨存儲的字元串的大小是一樣的。

使用 Categoricals 優化 object 類型

pandas 在 0.15 版引入了 Categorials。category 類型在底層使用了整型值來表示一個列中的值,而不是使用原始值。pandas 使用一個單獨的映射詞典将這些整型值映射到原始值。隻要當一個列包含有限的值的集合時,這種方法就很有用。當我們将一列轉換成 category dtype 時,pandas 就使用最節省空間的 int 子類型來表示該列中的所有不同值。

簡單又實用的pandas技巧:如何将記憶體占用降低90%

為了了解為什麼我們可以使用這種類型來減少記憶體用量,讓我們看看我們的 object 類型中每種類型的不同值的數量。

簡單又實用的pandas技巧:如何将記憶體占用降低90%

上圖完整圖像詳見原文

大概看看就能發現,對于我們整個資料集的 172,000 場比賽,其中不同(unique)值的數量可以說非常少。

為了了解當我們将其轉換成 categorical 類型時究竟發生了什麼,我們拿出一個 object 列來看看。我們将使用資料集的第二列 day_of_week.

看看上表,可以看到其僅包含 7 個不同的值。我們将使用 .astype() 方法将其轉換成 categorical 類型。

如你所見,除了這一列的類型發生了改變之外,資料看起來還是完全一樣。讓我們看看這背後發生了什麼。

在下面的代碼中,我們使用了 Series.cat.codes 屬性來傳回 category 類型用來表示每個值的整型值。

你可以看到每個不同值都被配置設定了一個整型值,而該列現在的基本資料類型是 int8。這一列沒有任何缺失值,但就算有,category 子類型也能處理,隻需将其設定為 -1 即可。

最後,讓我們看看在将這一列轉換為 category 類型前後的記憶體用量對比。

9.8 MB 的記憶體用量減少到了 0.16 MB,減少了 98%!注意,這個特定列可能代表了我們最好的情況之一——即大約 172,000 項卻隻有 7 個不同的值。

盡管将所有列都轉換成這種類型聽起來很吸引人,但了解其中的取舍也很重要。最大的壞處是無法執行數值計算。如果沒有首先将其轉換成數值 dtype,那麼我們就無法對 category 列進行算術運算,也就是說無法使用 Series.min() 和 Series.max() 等方法。

我們将編寫一個循環函數來疊代式地檢查每一 object 列中不同值的數量是否少于 50%;如果是,就将其轉換成 category 類型。

和之前一樣進行比較:

簡單又實用的pandas技巧:如何将記憶體占用降低90%

在這個案例中,所有的 object 列都被轉換成了 category 類型,但并非所有資料集都是如此,是以你應該使用上面的流程進行檢查。

object 列的記憶體用量從 752MB 減少到了 52MB,減少了 93%。讓我們将其與我們 dataframe 的其它部分結合起來,看看從最初 861MB 的基礎上實作了多少進步。

Wow,進展真是不錯!我們還可以執行另一項優化——如果你記得前面給出的資料類型表,你知道還有一個 datetime 類型。這個資料集的第一列就可以使用這個類型。

你可能記得這一列開始是一個整型,現在已經優化成了 unint32 類型。是以,将其轉換成 datetime 類型實際上會讓記憶體用量翻倍,因為 datetime 類型是 64 位的。将其轉換成 datetime 類型是有價值的,因為這讓我們可以更好地進行時間序列分析。

pandas.to_datetime() 函數可以幫我們完成這種轉換,使用其 format 參數将我們的日期資料存儲成 YYYY-MM-DD 形式。

在讀入資料的同時選擇類型

現在,我們已經探索了減少現有 dataframe 的記憶體占用的方法。通過首先讀入 dataframe,然後在這個過程中疊代以減少記憶體占用,我們了解了每種優化方法可以帶來的記憶體減省量。但是正如我們前面提到的一樣,我們往往沒有足夠的記憶體來表示資料集中的所有值。如果我們一開始甚至無法建立 dataframe,我們又可以怎樣應用節省記憶體的技術呢?

幸運的是,我們可以在讀入資料的同時指定最優的列類型。pandas.read_csv() 函數有幾個不同的參數讓我們可以做到這一點。dtype 參數接受具有(字元串)列名稱作為鍵值(key)以及 NumPy 類型 object 作為值的詞典。

首先,我們可将每一列的最終類型存儲在一個詞典中,其中鍵值表示列名稱,首先移除日期列,因為日期列需要不同的處理方式。

現在我們可以使用這個詞典了,另外還有幾個參數可用于按正确的類型讀入日期,而且僅需幾行代碼:

簡單又實用的pandas技巧:如何将記憶體占用降低90%

通過優化這些列,我們成功将 pandas 的記憶體占用從 861.6MB 減少到了 104.28MB——減少了驚人的 88%!

分析棒球比賽

現在我們已經優化好了我們的資料,我們可以執行一些分析了。讓我們先從了解這些比賽的日期分布開始。

簡單又實用的pandas技巧:如何将記憶體占用降低90%

我們可以看到在 1920 年代以前,星期日的棒球比賽很少,但在上個世紀後半葉就變得越來越多了。

我們也可以清楚地看到過去 50 年來,比賽的日期分布基本上沒什麼大變化了。

讓我們再看看比賽時長的變化情況:

簡單又實用的pandas技巧:如何将記憶體占用降低90%

從 1940 年代以來,棒球比賽的持續時間越來越長。

總結和下一步

我們已經了解了 pandas 使用不同資料類型的方法,然後我們使用這種知識将一個 pandas dataframe 的記憶體用量減少了近 90%,而且也僅使用了一些簡單的技術:

将數值列向下轉換成更高效的類型

将字元串列轉換成 categorical 類型

簡單又實用的pandas技巧:如何将記憶體占用降低90%

from:https://www.jiqizhixin.com/articles/2018-03-07-3

繼續閱讀