簡單實用的pandas技巧:如何將內存佔用下降90%

文章選自DATAQUEST,做者:Josh Devlin,機器之心編譯, 原文連接點此跳轉
pandas 是一個 Python 軟件庫,可用於數據操做和分析。數據科學博客 Dataquest.io 發佈了一篇關於如何優化 pandas 內存佔用的教程:僅需進行簡單的數據類型轉換,就可以將一個棒球比賽數據集的內存佔用減小了近 90%,機器之心對本教程進行了編譯介紹。
當使用 pandas 操做小規模數據(低於 100 MB)時,性能通常不是問題。而當面對更大規模的數據(100 MB 到數 GB)時,性能問題會讓運行時間變得更漫長,並且會由於內存不足致使運行徹底失敗。
儘管 Spark 這樣的工具能夠處理大型數據集(100 GB 到數 TB),但要徹底利用它們的能力,每每須要更加昂貴的硬件。並且和 pandas 不一樣,它們缺乏豐富的用於高質量數據清理、探索和分析的功能集。對於中等規模的數據,咱們最好能更充分地利用 pandas,而不是換成另外一種工具。
在這篇文章中,咱們將瞭解 pandas 的內存使用,以及如何只需經過爲列選擇合適的數據類型就能將 dataframe 的內存佔用減小近 90%。

處理棒球比賽日誌

咱們將處理 130 年之久的美國職業棒球大聯盟(MLB)比賽數據,這些數據來自 Retrosheet:http://www.retrosheet.org/gamelogs/index.html。
這些數據原來分紅了 127 個不一樣的 CSV 文件,但咱們已經使用 csvkit 合併了這些數據,並在第一行增長了列名稱。若是你想下載本文所用的這個數據版本,請訪問:https://data.world/dataquest/mlb-game-logs。
讓咱們首先導入數據,並看看其中的前五行:
import pandas as pd

gl = pd.read_csv('game_logs.csv')
gl.head()複製代碼
下面咱們總結了一些重要的列,但若是你想了解全部的列,咱們也爲整個數據集建立了一個數據詞典:https://data.world/dataquest/mlb-game-logs/workspace/data-dictionary。
  • 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',以便獲得準確的數字。
gl.info(memory_usage='deep')複製代碼

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 171907 entries, 0 to 171906
Columns: 161 entries, date to acquisition_info
dtypes: float64(77), int64(6), object(78)
memory usage: 861.6 MB複製代碼
咱們能夠看到,咱們有 171,907 行和 161 列。pandas 會自動爲咱們檢測數據類型,發現其中有 83 列數據是數值,78 列是 object。object 是指有字符串或包含混合數據類型的狀況。
爲了更好地理解如何減小內存用量,讓咱們看看 pandas 是如何將數據存儲在內存中的。

dataframe 的內部表示

在 pandas 內部,一樣數據類型的列會組織成同一個值塊(blocks of values)。這裏給出了一個示例,說明了 pandas 對咱們的 dataframe 的前 12 列的存儲方式。
你能夠看到這些塊並無保留原有的列名稱。這是由於這些塊爲存儲 dataframe 中的實際值進行了優化。pandas 的 BlockManager 類則負責保留行列索引與實際塊之間的映射關係。它能夠做爲一個 API 使用,提供了對底層數據的訪問。無論咱們什麼時候選擇、編輯或刪除這些值,dataframe 類和 BlockManager 類的接口都會將咱們的請求翻譯成函數和方法的調用。
在 pandas.core.internals 模塊中,每一種類型都有一個專門的類。pandas 使用 ObjectBlock 類來表示包含字符串列的塊,用 FloatBlock 類表示包含浮點數列的塊。對於表示整型數和浮點數這些數值的塊,pandas 會將這些列組合起來,存儲成 NumPy ndarray。NumPy ndarray 是圍繞 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 MB
Average memory usage for int columns: 1.12 MB
Average memory usage for object columns: 9.53 MB複製代碼
能夠看出,78 個 object 列所使用的內存量最大。咱們後面再具體談這個問題。首先咱們看看可否改進數值列的內存用量。

理解子類型(subtype)

正如咱們前面簡單提到的那樣,pandas 內部將數值表示爲 NumPy ndarrays,並將它們存儲在內存的連續塊中。這種存儲模式佔用的空間更少,並且也讓咱們能夠快速訪問這些值。由於 pandas 表示同一類型的每一個值時都使用一樣的字節數,而 NumPy ndarray 能夠存儲值的數量,因此 pandas 能夠快速準確地返回一個數值列所消耗的字節數。
pandas 中的許多類型都有多個子類型,這些子類型可使用更少的字節來表示每一個值。好比說 float 類型就包含 float1六、float32 和 float64 子類型。類型名稱中的數字就表明該類型表示值的位(bit)數。好比說,咱們剛剛列出的子類型就分別使用了 二、四、八、16 個字節。下面的表格給出了 pandas 中最經常使用類型的子類型:
一個 int8 類型的值使用 1 個字節的存儲空間,能夠表示 256(2^8)個二進制數。這意味着咱們可使用這個子類型來表示從 -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(無符號整型)和 int(有符號整型)之間的差別。這兩種類型都有同樣的存儲能力,但其中一個只保存 0 和正數。無符號整型讓咱們能夠更有效地處理只有正數值的列。

使用子類型優化數值列

咱們可使用函數 pd.to_numeric() 來對咱們的數值類型進行 downcast(向下轉型)操做。咱們會使用 DataFrame.select_dtypes 來選擇整型列,而後咱們會對其數據類型進行優化,並比較內存用量。
# We're going to be calculating memory usage a lot,
# so we'll 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 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)

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 MB
1.48 MB複製代碼
咱們能夠看到內存用量從 7.9 MB 降低到了 1.5 MB,下降了 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)複製代碼

100.99 MB
50.49 MB複製代碼
咱們能夠看到浮點型列的數據類型從 float64 變成了 float32,讓內存用量下降了 50%。
讓咱們爲原始 dataframe 建立一個副本,並用這些優化後的列替換原來的列,而後看看咱們如今的總體內存用量。
optimized_gl = gl.copy()

optimized_gl[converted_int.columns] = converted_int
optimized_gl[converted_float.columns] = converted_float

print(mem_usage(gl))
print(mem_usage(optimized_gl))複製代碼

861.57 MB複製代碼

804.69 MB複製代碼
儘管咱們極大地減小了數值列的內存用量,但總體的內存用量僅減小了 7%。咱們的大部分收穫都未來自對 object 類型的優化。
在咱們開始行動以前,先看看 pandas 中字符串的存儲方式與數值類型的存儲方式的比較。

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

object 類型表示使用 Python 字符串對象的值,部分緣由是 NumPy 不支持缺失(missing)字符串類型。由於 Python 是一種高級的解釋性語言,它對內存中存儲的值沒有細粒度的控制能力。
這一限制致使字符串的存儲方式很碎片化,從而會消耗更多內存,並且訪問速度也更慢。object 列中的每一個元素實際上都是一個指針,包含了實際值在內存中的位置的「地址」。
下面這幅圖給出了以 NumPy 數據類型存儲數值數據和使用 Python 內置類型存儲字符串數據的方式。
圖片來源:https://jakevdp.github.io/blog/2014/05/09/why-python-is-slow/
在前面的表格中,你可能已經注意到 object 類型的內存使用是可變的。儘管每一個指針僅佔用 1 字節的內存,但若是每一個字符串在 Python 中都是單獨存儲的,那就會佔用實際字符串那麼大的空間。咱們可使用 sys.getsizeof() 函數來證實這一點,首先查看單個的字符串,而後查看 pandas series 中的項。
from sys import getsizeof

s1 = 'working out'
s2 = 'memory usage for'
s3 = 'strings in python is fun!'
s4 = 'strings in python is fun!'

for s in [s1, s2, s3, s4]:
    print(getsizeof(s))複製代碼

60
65
74
74複製代碼

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

0    60
1    65
2    74
3    74
dtype: int64複製代碼
你能夠看到,當存儲在 pandas series 時,字符串的大小與用 Python 單獨存儲的字符串的大小是同樣的。

使用 Categoricals 優化 object 類型

pandas 在 0.15 版引入了 Categorials。category 類型在底層使用了整型值來表示一個列中的值,而不是使用原始值。pandas 使用一個單獨的映射詞典將這些整型值映射到原始值。只要當一個列包含有限的值的集合時,這種方法就頗有用。當咱們將一列轉換成 category dtype 時,pandas 就使用最節省空間的 int 子類型來表示該列中的全部不一樣值。
爲了瞭解爲何咱們可使用這種類型來減小內存用量,讓咱們看看咱們的 object 類型中每種類型的不一樣值的數量。
gl_obj = gl.select_dtypes(include=['object']).copy()
gl_obj.describe()複製代碼
上圖完整圖像詳見原文
大概看看就能發現,對於咱們整個數據集的 172,000 場比賽,其中不一樣(unique)值的數量能夠說很是少。
爲了瞭解當咱們將其轉換成 categorical 類型時究竟發生了什麼,咱們拿出一個 object 列來看看。咱們將使用數據集的第二列 day_of_week.
看看上表,能夠看到其僅包含 7 個不一樣的值。咱們將使用 .astype() 方法將其轉換成 categorical 類型。
dow = gl_obj.day_of_week
print(dow.head())

dow_cat = dow.astype('category')
print(dow_cat.head())複製代碼

0    Thu
1    Fri
2    Sat
3    Mon
4    Tue
Name: day_of_week, dtype: object
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]複製代碼
如你所見,除了這一列的類型發生了改變以外,數據看起來仍是徹底同樣。讓咱們看看這背後發生了什麼。
在下面的代碼中,咱們使用了 Series.cat.codes 屬性來返回 category 類型用來表示每一個值的整型值。
dow_cat.head().cat.codes複製代碼

0    4
1    0
2    2
3    1
4    5
dtype: int8複製代碼
你能夠看到每一個不一樣值都被分配了一個整型值,而該列如今的基本數據類型是 int8。這一列沒有任何缺失值,但就算有,category 子類型也能處理,只需將其設置爲 -1 便可。
最後,讓咱們看看在將這一列轉換爲 category 類型先後的內存用量對比。
print(mem_usage(dow))
print(mem_usage(dow_cat))複製代碼

9.84 MB
0.16 MB複製代碼
9.8 MB 的內存用量減小到了 0.16 MB,減小了 98%!注意,這個特定列可能表明了咱們最好的狀況之一——即大約 172,000 項卻只有 7 個不一樣的值。
儘管將全部列都轉換成這種類型聽起來很吸引人,但瞭解其中的取捨也很重要。最大的壞處是沒法執行數值計算。若是沒有首先將其轉換成數值 dtype,那麼咱們就沒法對 category 列進行算術運算,也就是說沒法使用 Series.min() 和 Series.max() 等方法。
咱們應該堅持主要將 category 類型用於不一樣值的數量少於值的總數量的 50% 的 object 列。若是一列中的全部值都是不一樣的,那麼 category 類型所使用的內存將會更多。由於這一列不只要存儲全部的原始字符串值,還要額外存儲它們的整型值代碼。你能夠在 pandas 文檔中瞭解 category 類型的侷限性:http://pandas.pydata.org/pandas-docs/stable/categorical.html。
咱們將編寫一個循環函數來迭代式地檢查每一 object 列中不一樣值的數量是否少於 50%;若是是,就將其轉換成 category 類型。
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 MB
51.67 MB複製代碼
在這個案例中,全部的 object 列都被轉換成了 category 類型,但並不是全部數據集都是如此,因此你應該使用上面的流程進行檢查。
object 列的內存用量從 752MB 減小到了 52MB,減小了 93%。讓咱們將其與咱們 dataframe 的其它部分結合起來,看看從最初 861MB 的基礎上實現了多少進步。
optimized_gl[converted_obj.columns] = converted_obj

mem_usage(optimized_gl)複製代碼

'103.64 MB'複製代碼
Wow,進展真是不錯!咱們還能夠執行另外一項優化——若是你記得前面給出的數據類型表,你知道還有一個 datetime 類型。這個數據集的第一列就可使用這個類型。
date = optimized_gl.date
print(mem_usage(date))
date.head()複製代碼

0.66 MB複製代碼

0    18710504
1    18710505
2    18710506
3    18710508
4    18710509
Name: date, dtype: uint32複製代碼
你可能記得這一列開始是一個整型,如今已經優化成了 unint32 類型。所以,將其轉換成 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 MB複製代碼

0   1871-05-04
1   1871-05-05
2   1871-05-06
3   1871-05-08
4   1871-05-09
Name: date, dtype: datetime64[ns]複製代碼

在讀入數據的同時選擇類型

如今,咱們已經探索了減小現有 dataframe 的內存佔用的方法。經過首先讀入 dataframe,而後在這個過程當中迭代以減小內存佔用,咱們瞭解了每種優化方法能夠帶來的內存減省量。可是正如咱們前面提到的同樣,咱們每每沒有足夠的內存來表示數據集中的全部值。若是咱們一開始甚至沒法建立 dataframe,咱們又能夠怎樣應用節省內存的技術呢?
幸運的是,咱們能夠在讀入數據的同時指定最優的列類型。pandas.read_csv() 函數有幾個不一樣的參數讓咱們能夠作到這一點。dtype 參數接受具備(字符串)列名稱做爲鍵值(key)以及 NumPy 類型 object 做爲值的詞典。
首先,咱們可將每一列的最終類型存儲在一個詞典中,其中鍵值表示列名稱,首先移除日期列,由於日期列須要不一樣的處理方式。
dtypes = optimized_gl.drop('date',axis=1).dtypes

dtypes_col = dtypes.index
dtypes_type = [i.name for i in dtypes.values]

column_types = dict(zip(dtypes_col, dtypes_type))

# rather than print all 161 items, we'll
# sample 10 key/value pairs from the dict
# and print it nicely using prettyprint

preview = first2pairs = {key:value for key,value in list(column_types.items())[:10]}
import pprint
pp = 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'}複製代碼
如今咱們可使用這個詞典了,另外還有幾個參數可用於按正確的類型讀入日期,並且僅需幾行代碼:
read_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.6MB 減小到了 104.28MB——減小了驚人的 88%!

分析棒球比賽

如今咱們已經優化好了咱們的數據,咱們能夠執行一些分析了。讓咱們先從瞭解這些比賽的日期分佈開始。
optimized_gl['year'] = optimized_gl.date.dt.year
games_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%,並且也僅使用了一些簡單的技術:
  • 將數值列向下轉換成更高效的類型
  • 將字符串列轉換成 categorical 類型
若是你還想使用 pandas 處理更大規模的數據,能夠參與這個交互式課程:https://www.dataquest.io/m/163/optimizing-dataframe-memory-footprint/16/next-steps。
相關文章
相關標籤/搜索