簡單實用的pandas技巧:如何將記憶體佔用降低90%

機器之心發表於2019-03-04
文章選自DATAQUEST,作者:Josh Devlin,機器之心編譯,原文連結點此跳轉
pandas 是一個 Python 軟體庫,可用於資料操作和分析。資料科學部落格 Dataquest.io 釋出了一篇關於如何優化 pandas 記憶體佔用的教程:僅需進行簡單的資料型別轉換,就能夠將一個棒球比賽資料集的記憶體佔用減少了近 90%,機器之心對本教程進行了編譯介紹。
當使用 pandas 操作小規模資料(低於 100 MB)時,效能一般不是問題。而當面對更大規模的資料(100 MB 到數 GB)時,效能問題會讓執行時間變得更漫長,而且會因為記憶體不足導致執行完全失敗。
儘管 Spark 這樣的工具可以處理大型資料集(100 GB 到數 TB),但要完全利用它們的能力,往往需要更加昂貴的硬體。而且和 pandas 不同,它們缺少豐富的用於高質量資料清理、探索和分析的功能集。對於中等規模的資料,我們最好能更充分地利用 pandas,而不是換成另一種工具。
在這篇文章中,我們將瞭解 pandas 的記憶體使用,以及如何只需通過為列選擇合適的資料型別就能將 dataframe 的記憶體佔用減少近 90%。
簡單實用的pandas技巧:如何將記憶體佔用降低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 列的儲存方式。
簡單實用的pandas技巧:如何將記憶體佔用降低90%
你可以看到這些塊並沒有保留原有的列名稱。這是因為這些塊為儲存 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 型別就包含 float16、float32 和 float64 子型別。型別名稱中的數字就代表該型別表示值的位(bit)數。比如說,我們剛剛列出的子型別就分別使用了 2、4、8、16 個位元組。下面的表格給出了 pandas 中最常用型別的子型別:
簡單實用的pandas技巧:如何將記憶體佔用降低90%
一個 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複製程式碼
簡單實用的pandas技巧:如何將記憶體佔用降低90%
我們可以看到記憶體用量從 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複製程式碼
簡單實用的pandas技巧:如何將記憶體佔用降低90%
我們可以看到浮點型列的資料型別從 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 內建型別儲存字串資料的方式。
簡單實用的pandas技巧:如何將記憶體佔用降低90%
圖片來源: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 子型別來表示該列中的所有不同值。
簡單實用的pandas技巧:如何將記憶體佔用降低90%
為了瞭解為什麼我們可以使用這種型別來減少記憶體用量,讓我們看看我們的 object 型別中每種型別的不同值的數量。
gl_obj = gl.select_dtypes(include=[`object`]).copy()
gl_obj.describe()複製程式碼
簡單實用的pandas技巧:如何將記憶體佔用降低90%
上圖完整影像詳見原文
大概看看就能發現,對於我們整個資料集的 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複製程式碼
簡單實用的pandas技巧:如何將記憶體佔用降低90%
在這個案例中,所有的 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技巧:如何將記憶體佔用降低90%
上圖完整影像詳見原文
通過優化這些列,我們成功將 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()複製程式碼
簡單實用的pandas技巧:如何將記憶體佔用降低90%
我們可以看到在 1920 年代以前,星期日的棒球比賽很少,但在上個世紀後半葉就變得越來越多了。
我們也可以清楚地看到過去 50 年來,比賽的日期分佈基本上沒什麼大變化了。
讓我們再看看比賽時長的變化情況:
game_lengths = optimized_gl.pivot_table(index=`year`, values=`length_minutes`)
game_lengths.reset_index().plot.scatter(`year`,`length_minutes`)
plt.show()複製程式碼
簡單實用的pandas技巧:如何將記憶體佔用降低90%
從 1940 年代以來,棒球比賽的持續時間越來越長。

總結和下一步

我們已經瞭解了 pandas 使用不同資料型別的方法,然後我們使用這種知識將一個 pandas dataframe 的記憶體用量減少了近 90%,而且也僅使用了一些簡單的技術:
  • 將數值列向下轉換成更高效的型別
  • 將字串列轉換成 categorical 型別
如果你還想使用 pandas 處理更大規模的資料,可以參與這個互動式課程:https://www.dataquest.io/m/163/optimizing-dataframe-memory-footprint/16/next-steps。

相關文章