【精心解讀】用pandas處理大數據——節省90%內存消耗的小貼士

編譯:西西、wally21st

未經允許,不得轉載

原文鏈接:用pandas處理大數據——節省90%內存消耗的小貼士

一般來說,用pandas處理小於100兆的數據,性能不是問題。當用pandas來處理100兆至幾個G的數據時,將會比較耗時,同時會導致程序因內存不足而運行失敗。

當然,像Spark這類的工具能夠勝任處理100G至幾個T的大數據集,但要想充分發揮這些工具的優勢,通常需要比較貴的硬體設備。而且,這些工具不像pandas那樣具有豐富的進行高質量數據清洗、探索和分析的特性。對於中等規模的數據,我們的願望是盡量讓pandas繼續發揮其優勢,而不是換用其他工具。

本文我們討論pandas的內存使用,展示怎樣簡單地為數據列選擇合適的數據類型,就能夠減少dataframe近90%的內存佔用。

處理棒球比賽記錄數據

我們將處理130年的棒球甲級聯賽的數據,數據源於Retrosheet

原始數據放在127個csv文件中,我們已經用csvkit將其合併,並添加了表頭。如果你想下載我們版本的數據用來運行本文的程序,我們提供了下載地址

我們從導入數據,並輸出前5行開始:

import pandas as pdgl = pd.read_csv(game_logs.csv)gl.head()

我們將一些重要的欄位列在下面:

  • 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 - 主辦場地的ID
  • attendance- 比賽出席人數

我們可以用Dataframe.info()方法來獲得我們dataframe的一些高level信息,譬如數據量、數據類型和內存使用量。

這個方法默認情況下返回一個近似的內存使用量,現在我們設置參數memory_usagedeep來獲得準確的內存使用量:

gl.info(memory_usage=deep)p

<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

我們可以看到它有171907行和161列。pandas已經為我們自動檢測了數據類型,其中包括83列數值型數據和78列對象型數據。對象型數據列用於字元串或包含混合數據類型的列。

由此我們可以進一步了解我們應該如何減少內存佔用,下面我們來看一看pandas如何在內存中存儲數據。

Dataframe對象的內部表示

在底層,pandas會按照數據類型將列分組形成數據塊(blocks)。下圖所示為pandas如何存儲我們數據表的前十二列:

可以注意到,這些數據塊沒有保持對列名的引用,這是由於為了存儲dataframe中的真實數據,這些數據塊都經過了優化。有個BlockManager類會用於保持行列索引與真實數據塊的映射關係。他扮演一個API,提供對底層數據的訪問。每當我們查詢、編輯或刪除數據時,dataframe類會利用BlockManager類介面將我們的請求轉換為函數和方法的調用。

每種數據類型在pandas.core.internals模塊中都有一個特定的類。pandas使用ObjectBlock類來表示包含字元串列的數據塊,用FloatBlock類來表示包含浮點型列的數據塊。對於包含數值型數據(比如整型和浮點型)的數據塊,pandas會合併這些列,並把它們存儲為一個Numpy數組(ndarray)。Numpy數組是在C數組的基礎上創建的,其值在內存中是連續存儲的。基於這種存儲機制,對其切片的訪問是相當快的。

由於不同類型的數據是分開存放的,我們將檢查不同數據類型的內存使用情況,我們先看看各數據類型的平均內存使用量:

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))

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

我們可以看到內存使用最多的是78個object列,我們待會再來看它們,我們先來看看我們能否提高數值型列的內存使用效率。

理解子類(Subtypes)

剛才我們提到,pandas在底層將數值型數據表示成Numpy數組,並在內存中連續存儲。這種存儲方式消耗較少的空間,並允許我們較快速地訪問數據。由於pandas使用相同數量的位元組來表示同一類型的每一個值,並且numpy數組存儲了這些值的數量,所以pandas能夠快速準確地返回數值型列所消耗的位元組量。

pandas中的許多數據類型具有多個子類型,它們可以使用較少的位元組去表示不同數據,比如,float型就有float16、float32和float64這些子類型。這些類型名稱的數字部分表明了這種類型使用了多少比特來表示數據,比如剛才列出的子類型分別使用了2、4、8個位元組。下面這張表列出了pandas中常用類型的子類型:

一個int8類型的數據使用1個位元組(8位比特)存儲一個值,可以表示256(2^8)個二進位數值。這意味著我們可以用這種子類型去表示從-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 = 255-----------------------------------------------------Machine parameters for int8-----------------------------------------------------min = -128max = 127-----------------------------------------------------Machine parameters for int16-----------------------------------------------------min = -32768max = 32767-----------------------------------------------------

這裡我們還可以看到uint(無符號整型)和int(有符號整型)的區別。兩者都佔用相同的內存存儲量,但無符號整型由於只存正數,所以可以更高效的存儲只含正數的列。

用子類型優化數值型列

我們可以用函數pd.to_numeric()來對數值型進行向下類型轉換。我們用DataFrame.select_dtypes來只選擇整型列,然後我們優化這種類型,並比較內存使用量。

# Were going to be calculating memory usage a lot,# so well create a function to save us some time!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 its 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)gl_int = gl.select_dtypes(include=[int])converted_int = gl_int.apply(pd.to_numeric,downcast=unsigned)print(mem_usage(gl_int))print(mem_usage(converted_int))compare_ints = pd.concat([gl_int.dtypes,converted_int.dtypes],axis=1)compare_ints.columns = [before,after]compare_ints.apply(pd.Series.value_counts)

7.87 MB1.48 MB

我們看到內存用量從7.9兆下降到1.5兆,降幅達80%。這對我們原始dataframe的影響有限,這是由於它只包含很少的整型列。

同理,我們再對浮點型列進行相應處理:

gl_float = gl.select_dtypes(include=[float])converted_float = gl_float.apply(pd.to_numeric,downcast=float)print(mem_usage(gl_float))print(mem_usage(converted_float))compare_floats = pd.concat([gl_float.dtypes,converted_float.dtypes],axis=1)compare_floats.columns = [before,after]compare_floats.apply(pd.Series.value_counts)

我們可以看到所有的浮點型列都從float64轉換為float32,內存用量減少50%。

我們再創建一個原始dataframe的副本,將其數值列賦值為優化後的類型,再看看內存用量的整體優化效果。

optimized_gl = gl.copy()optimized_gl[converted_int.columns] = converted_intoptimized_gl[converted_float.columns] = converted_floatprint(mem_usage(gl))print(mem_usage(optimized_gl))

可以看到通過我們顯著縮減數值型列的內存用量,我們的dataframe的整體內存用量減少了7%。餘下的大部分優化將針對object類型進行。

在這之前,我們先來研究下與數值型相比,pandas如何存儲字元串。

選對比數值與字元的儲存

object類型用來表示用到了Python字元串對象的值,有一部分原因是Numpy缺少對缺失字元串值的支持。因為Python是一種高層、解析型語言,它沒有提供很好的對內存中數據如何存儲的細粒度控制。

這一限制導致了字元串以一種碎片化方式進行存儲,消耗更多的內存,並且訪問速度低下。在object列中的每一個元素實際上都是存放內存中真實數據位置的指針。

下圖對比展示了數值型數據怎樣以Numpy數據類型存儲,和字元串怎樣以Python內置類型進行存儲的。

圖示來源並改編自Why Python Is Slow

你可能注意到上文表中提到object類型數據使用可變(variable)大小的內存。由於一個指針佔用1位元組,因此每一個字元串佔用的內存量與它在Python中單獨存儲所佔用的內存量相等。我們用sys.getsizeof()來證明這一點,先來看看在Python單獨存儲字元串,再來看看使用pandas的series的情況。

from sys import getsizeofs1 = working outs2 = memory usage fors3 = strings in python is fun!s4 = strings in python is fun!for s in [s1, s2, s3, s4]: print(getsizeof(s))

60657474

obj_series = pd.Series([working out, memory usage for, strings in python is fun!, strings in python is fun!])obj_series.apply(getsizeof)

0 601 652 743 74dtype: int64

你可以看到這些字元串的大小在pandas的series中與在Python的單獨字元串中是一樣的。

用類別(categoricalas)類型優化object類型

Pandas在0.15版本中引入類別類型。category類型在底層使用整型數值來表示該列的值,而不是用原值。Pandas用一個字典來構建這些整型數據到原數據的映射關係。當一列只包含有限種值時,這種設計是很不錯的。當我們把一列轉換成category類型時,pandas會用一種最省空間的int子類型去表示這一列中所有的唯一值。

為了介紹我們何處會用到這種類型去減少內存消耗,讓我們來看看我們數據中每一個object類型列中的唯一值個數。

l_obj = gl.select_dtypes(include=[object]).copy()gl_obj.describe()

可以看到在我們包含了近172000場比賽的數據集中,很多列只包含了少數幾個唯一值。

我們先選擇其中一個object列,開看看將其轉換成類別類型會發生什麼。這裡我們選用第二列:day_of_week。

我們從上表中可以看到,它只包含了7個唯一值。我們用.astype()方法將其轉換為類別類型。

dow = gl_obj.day_of_weekprint(dow.head())dow_cat = dow.astype(category)print(dow_cat.head())

0 Thu1 Fri2 Sat3 Mon4 TueName: day_of_week, dtype: object0 Thu1 Fri2 Sat3 Mon4 TueName: day_of_week, dtype: categoryCategories (7, object): [Fri, Mon, Sat, Sun, Thu, Tue, Wed]

可以看到,雖然列的類型改變了,但數據看上去好像沒什麼變化。我們來看看底層發生了什麼。

下面的代碼中,我們用Series.cat.codes屬性來返回category類型用以表示每個值的整型數字。

dow_cat.head().cat.codes

0 41 02 23 14 5dtype: int8

可以看到,每一個值都被賦值為一個整數,而且這一列在底層是int8類型。這一列沒有任何缺失數據,但是如果有,category子類型會將缺失數據設為-1。

最後,我們來看看這一列在轉換為category類型前後的內存使用量。

print(mem_usage(dow))print(mem_usage(dow_cat))9.84 MB0.16 MB

內存用量從9.8兆降到0.16兆,近乎98%的降幅!注意這一特殊列可能代表了我們一個極好的例子——一個包含近172000個數據的列只有7個唯一值。

這樣的話,我們把所有這種類型的列都轉換成類別類型應該會很不錯,但這裡面也要權衡利弊。首要問題是轉變為類別類型會喪失數值計算能力,在將類別類型轉換成真實的數值類型前,我們不能對category列做算術運算,也不能使用諸如Series.min()和Series.max()等方法。

對於唯一值數量少於50%的object列,我們應該堅持首先使用category類型。如果某一列全都是唯一值,category類型將會佔用更多內存。這是因為這樣做不僅要存儲全部的原始字元串數據,還要存儲整型類別標識。有關category類型的更多限制,參看pandas文檔

下面我們寫一個循環,對每一個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))print(mem_usage(converted_obj))compare_obj = pd.concat([gl_obj.dtypes,converted_obj.dtypes],axis=1)compare_obj.columns = [before,after]compare_obj.apply(pd.Series.value_counts)752.72 MB51.67 MB

這本例中,所有的object列都被轉換成了category類型,但其他數據集就不一定了,所以你最好還是得使用剛才的檢查過程。

本例的亮點是內存用量從752.72兆降為51.667兆,降幅達93%。我們將其與我們dataframe的剩下部分合併,看看初始的861兆數據降到了多少。

optimized_gl[converted_obj.columns] = converted_objmem_usage(optimized_gl)103.64 MB

耶,看來我們的進展還不錯!我們還有一招可以做優化,如果你記得我們剛才那張類型表,會發現我們數據集第一列還可以用datetime類型來表示。

date = optimized_gl.dateprint(mem_usage(date))date.head()0.66 MB0 187105041 187105052 187105063 187105084 18710509Name: date, dtype: uint32

你可能還記得這一列之前是作為整型讀入的,並優化成了uint32。因此,將其轉換成datetime會佔用原來兩倍的內存,因為datetime類型是64位比特的。將其轉換為datetime的意義在於它可以便於我們進行時間序列分析。

轉換使用pandas.to_datetime()函數,並使用format參數告之日期數據存儲為YYYY-MM-DD格式。

optimized_gl[date] = pd.to_datetime(date,format=%Y%m%d)print(mem_usage(optimized_gl))optimized_gl.date.head()104.29 MB0 1871-05-041 1871-05-052 1871-05-063 1871-05-084 1871-05-09Name: date, dtype: datetime64[ns]

在數據讀入的時候設定數據類型

目前為止,我們探索了一些方法,用來減少現有dataframe的內存佔用。通過首先讀入dataframe,再對其一步步進行內存優化,我們可以更好地了解這些優化方法能節省多少內存。然而,正如我們之前談到,我們通常沒有足夠的內存去表達數據集中的所有數據。如果不能在一開始就創建dataframe,我們怎樣才能應用內存節省技術呢?

幸運的是,我們可以在讀入數據集的時候指定列的最優數據類型。pandas.read_csv()函數有一些參數可以做到這一點。dtype參數接受一個以列名(string型)為鍵字典、以Numpy類型對象為值的字典。

首先,我們將每一列的目標類型存儲在以列名為鍵的字典中,開始前先刪除日期列,因為它需要分開單獨處理。

dtypes = optimized_gl.drop(date,axis=1).dtypesdtypes_col = dtypes.indexdtypes_type = [i.name for i in dtypes.values]column_types = dict(zip(dtypes_col, dtypes_type))# rather than print all 161 items, well# sample 10 key/value pairs from the dict# and print it nicely using prettyprintpreview = first2pairs = {key:value for key,value in list(column_types.items())[:10]}import pprintpp = pp = pprint.PrettyPrinter(indent=4)pp.pprint(preview){ acquisition_info: category, h_caught_stealing: float32, h_player_1_name: category, h_player_9_name: category, v_assists: float32, v_first_catcher_interference: float32, v_grounded_into_double: float32, v_player_1_id: category, v_player_3_id: category, v_player_5_id: category}

現在我們使用這個字典,同時傳入一些處理日期的參數,讓日期以正確的格式讀入。

ead_and_optimized = pd.read_csv(game_logs.csv,dtype=column_types,parse_dates=[date],infer_datetime_format=True)print(mem_usage(read_and_optimized))read_and_optimized.head()104.28 MB

通過對列的優化,我們是pandas的內存用量從861.6兆降到104.28兆,有效降低88%。

分析棒球比賽

現在我們有了優化後的數據,可以進行一些分析。我們先看看比賽日的分布情況。

optimized_gl[year] = optimized_gl.date.dt.yeargames_per_day = optimized_gl.pivot_table(index=year,columns=day_of_week,values=date,aggfunc=len)games_per_day = games_per_day.divide(games_per_day.sum(axis=1),axis=0)ax = games_per_day.plot(kind=area,stacked=true)ax.legend(loc=upper right)ax.set_ylim(0,1)plt.show()

我們可以看到,1920年代之前,周日棒球賽很少是在周日的,隨後半個世紀才逐漸增多。

我們也看到最後50年的比賽日分布變化相對比較平穩。

我們來看看比賽時長的逐年變化。

game_lengths = optimized_gl.pivot_table(index=year, values=length_minutes)game_lengths.reset_index().plot.scatter(year,length_minutes)plt.show()

看來棒球比賽時長從1940年代之後逐漸變長。

總結

我們學習了pandas如何存儲不同的數據類型,並利用學到的知識將我們的pandas dataframe的內存用量降低了近90%,僅僅只用了一點簡單的技巧:

  • 將數值型列降級到更高效的類型
  • 將字元串列轉換為類別類型

通過對列的優化,我們是pandas的內存用量從861.6兆降到104.28兆,有效降低88%。

原文鏈接:dataquest.io/blog/panda

推薦閱讀:

Python世界的神:metaclass
python生成器處理大文本文件的代碼
Flask 實現小說網站 (二)
學慣用python來自動化測試(一)
如何處理 Python 入門難以進步的現象?

TAG:大数据 | Python | 机器学习 |