前言
python作為一款不怎么關注數據類型的語言,不同類型的數據可以往同一個變量中放置
這也就直接導致,作為熟悉C++這種一個變量只有一個類型的程序員來說,在解讀python程序時,想搞清楚變量中到底存的是什么數據類型的時候時常很頭疼
所以說,良好的編程習慣真的很重要
雖然上面的話跟這篇文章關系不大,但是我就是想吐槽一下 ;p
下面是我優化某個python項目的其中某個模塊的內存時,對python中的numpy/pandas庫調研得出的一些優化操作,在此分享給大家
注:有的地方我理解的也不是很透徹,在此僅作為心得記錄,有任何不對的地方歡迎在評論區進行指正
一、通用優化操作
1. 指定合適的數據類型
如果數據的值域可以限制在較小的范圍內,可手動指定其dtype
類型進行限制
2. 避免copy
1)切片/索引層面
切片或其他索引方式中是否涉及拷貝,這部分就主要決定于各個庫中具體實現細節了,可看下面對應章節部分
- 原生python拷貝數組時注意的一個小細節:
list1 = [...]1.類似淺拷貝/引用,操作對象為同一個,無copy,操作list2就是操作list1: list2 = list1 2.類似深拷貝,涉及copy,list2與list1不是同一個對象: list2 = list1[:]
2)計算過程層面
如果輸入數據可以“原地”處理,且可以把計算過程拆分成“原地”處理的步驟,或是庫里提供了可以“原地”計算的函數,則盡量進行拆分和替換“原地”處理的函數
下面是數組歸一化
時,將步驟進行拆分節省內存的一個例子:
- 簡單的實現,但是會產生臨時數組的內存開銷
def Normalize(lst : list):lst_range = lst.max - lst.minreturn (lst - lst.min) / lst_range
- 拆分步驟,每一步都是“原地”操作
def Normalize(lst : list):lst_range = lst.max - lst.minlst -= lst.minlst /= lst_range
3. 文件讀取
- chunk讀取:這個也算是流程優化的一部分
讀取文件時是否是一次性讀取完畢?分塊讀取是否會影響到流程?如果可以分塊讀取,那么讀取接口是否有提供類似chunksize
的參數?
一次讀取的內容過多,一方面是會直接影響到IO內存,另一方面也是考慮到,大部分情況下實際要處理的數據并沒有那么多,所以不如限制每次讀取的數據大小,夠用即可,用完再取 - index索引:如果輸入數據是有組織有結構地進行存儲,那么通常可以通過建索引的方式記錄數據的關鍵信息,在需要取用的時候就能通過索引快速取用自己所需的部分,一方面加速處理速度,另一方面內存也能更低
4. 數據結構的選取
主要是選用跟實際需求更匹配的數據結構,比如dict會浪費至少30%以上的內存,如果條件允許,可以通過一系列的方式對其進行優化
- 換用自定義class
- class +
__slots__
:加上限定,能省略class內的一些隱性內存開銷 - namedtuple:同樣是限定,但與tuple類似不可更改
- recordclass:基于namedtuple的可變變體(使用方式可看這里)
二、numpy相關
1. 使用sparse array
雖然ndarray適用于存儲大量數據,但如果其中大部分數據都是空時,用稀疏矩陣能更省空間
2. 檢查庫方法的內部實現
庫提供的方法為了泛用性,內部一般會對輸入數據的類型進行限定,如果輸入數據的類型與其預期不符,可能會造成更大的內存消耗
比如:一個庫方法內部會將輸入數據轉為int64
進行操作,如果輸入的是int16
,那么消耗內存會是int16 + int64
,可能還不如把輸入數據直接設置為int64
并設置“原地”操作
3. indexing / slicing
numpy中有兩種indexing方式,不同的方式返回值不同:可能返回view(類似淺拷貝/引用),也可能返回copy
- 判斷view/copy方式:可以簡單地通過
.base
屬性是否為None來判斷 - basic indexing:
[]
中為slice/integer
或一個元組(僅包含slice/integer
)時觸發
與python原生list切片不同,返回view - Advanced indexing:
[]
中為非元組sequence / ndarray( of integer/bool)
或一個元組(包含至少一個sequence/ndarray
)時觸發
返回copy
示例:>>> import numpy as np >>> data1 = np.arange(9).reshape(3, 3) # reshape returns a view at most time >>> data1 array([[0, 1, 2],[3, 4, 5],[6, 7, 8]]) >>> data1.base array([0, 1, 2, 3, 4, 5, 6, 7, 8]) >>> >>> data2 = data1[[1, 2]] # data2 is a copy >>> data2 array([[3, 4, 5],[6, 7, 8]]) >>> data2.base >>> >>> data1[[1, 2]]=[[10, 11, 12], [13, 14, 15]] # change data1 >>> data1 # 可能在 = 左邊時不是copy?所以data1被改變了? array([[ 0, 1, 2],[10, 11, 12],[13, 14, 15]]) >>> data2 # data2 not changed array([[3, 4, 5],[6, 7, 8]])
.reshape()
:在大部分情況下,如果可以通過修改步長的方式來重建數組則會返回view;如果數組變得不再連續了(即修改步長重建不了了,比如ndarray.transpose
)則會返回copy
特殊情況:structured array
當ndarray的dtype使用named field
時,該數組變成一個有結構的數組(類似DataFrame一樣有名字,而且給每一列指定不同的dtype)
>>> x = np.array([('Rex', 9, 81.0), ('Fido', 3, 27.0)],dtype=[('name', 'U10'), ('age', 'i4'), ('weight', 'f4')])
>>> x
array([('Rex', 9, 81.), ('Fido', 3, 27.)], dtype=[('name', '<U10'), ('age', '<i4'), ('weight', '<f4')])
- Individual field:返回view
>>> x['age'] array([9, 3], dtype=int32) >>> x['age'] = 5 >>> x array([('Rex', 5, 81.), ('Fido', 5, 27.)], dtype=[('name', '<U10'), ('age', '<i4'), ('weight', '<f4')])
- Multiple fields:返回copy(版本<=1.15)或view(版本> 1.15)
4. 文件處理
numpy讀取文件時可使用memmap
對磁盤上的文件數據不讀取到內存的同時當作ndarray一樣去處理,但這個操作也存在一些限制:
- 如果讀取內容很大,那么這可能會成為程序的瓶頸,因為磁盤操作始終比內存操作要慢
- 如果需要不同維度的索引/切片,那么只有符合默認結構的索引/切片會快,其余的會非常慢
解決方案:可選用其他文件讀取庫(如Zarr/HDF5)
三、pandas相關
1. 使用sparse array
與numpy類似,DataFrame同樣可以設置成稀疏矩陣方式存儲數據
2. [pyarrow]
pandas內部實際是使用numpy.ndarray來表示數據,但其對于 字符串 或 缺失值 的處理并不友好
而在pandas版本2.1以后(至少2.0以后),引入的pyarrow
可以極大地優化這兩方面的處理
- 原本numpy中是用一組
PyObject*
存儲數據,額外空間開銷大;而pyarrow
中使用連續的char*
數組存儲,對于大量短字符串的優化效果尤其明顯 - 原本pandas中對于不同類型的缺失值有不同表示方式,而且對于整形的缺失值會自動轉成浮點型,比較麻煩;而
pyarrow
中對不同類型都一套實現,數據的表示進行了統一
使用方式:實際使用時只需要在原本數據類型后面加上[pyarrow]
即可。如:
string
->string[pyarrow]
int64
->int64[pyarrow]
- 讀取接口處可以指定
dtype_backend="pyarrow"
,有的還需指定engine="pyarrow"
3. indexing / slicing
非常重要的一點:pandas無法保證索引操作返回的是copy還是view
官方文檔Copy on Write (CoW)的 previous behavior 小節第一句用了tricky to understand,可見它是有多難懂了
該頁面主要介紹了讓操作結果更加 predictable 的策略CoW,主要思路就是禁止在一行代碼中更新多個對象
這樣如果有多個對象實際指向同一份數據時,如果嘗試更新其中一個對象的數據會觸發copy,這樣能保證另一份數據不會被影響;而且因為是更新時觸發,盡量延緩了內存增加的時間
那么之前的代碼中如果存在下面的操作時需格外注意,后續更新pandas版本后,結果可能與預期不符:
- chained indexing
一般表現為df[first_cond][second_cond] = ...
,即對兩個連續索引的結果進行更新
因為df[first_cond]
得到的不確定為copy還是view,所以這里實際不確定操作的是一個還是兩個對象(具體可看Why does assignment fail when using chained indexing)
注:可能還有其他形式的 chained indexing 需要格外注意,比如CoW中還提到下面的形式:df[col].replace(..., inplace=True)
- 解決辦法:使用
.loc
取代多個索引(因為.loc
操作能保證操作數據本身),或是將多個操作整合到一步操作中
注:如果DataFrame是df.replace(col:{conditions}, inplace=True) or df[col] = df[col].replace(conditions)
Multi-Index
的,那么兩個連續索引相當于是使用.loc
了,但是仍舊推薦使用.loc
4. 代碼優化
- 按行迭代:用
itertuples
替換iterrows
(速度更快) - 循環操作:用
.apply/.applymap
替換循環 - 大數據計算:使用
pd.eval
,在大數據上的較復雜計算才有速度優化效果