最后一次更新日期: 2019/3/21
此篇文章提供一個將scipy庫的稀疏矩陣運(yùn)用于卷積運(yùn)算上,以期在不增加過多內(nèi)存開銷的情況下提高性能的入門思路。
先導(dǎo)入以下模塊:
import numpy as np
from scipy import sparse
import time
1.基于滑窗的卷積運(yùn)算
卷積核
卷積核的三個軸分別對應(yīng):高,寬,顏色通道
In [5]: kernel=np.eye(3).repeat(3).reshape((3,3,3))
In [6]: kernel[:,:,0]
Out[6]:
array([[1., 0., 0.],
[0., 1., 0.],
[0., 0., 1.]])
圖片數(shù)據(jù)
以cifar10數(shù)據(jù)集作為示例,加載數(shù)據(jù)集的方法此處不作講解。
數(shù)據(jù)集的四個軸分別對應(yīng):樣本,高,寬,顏色通道
In [11]: cifar=CifarManager()
...: train_images,train_labels,test_images,test_labels=cifar.read_as_array(chinese_label=True)
...: The default path of image data is set to:
...: D:\training_data\used\cifar-10-batches-py
...: reading data---
...: completed
In [12]: test_images.shape
Out[12]: (10000, 32, 32, 3)
In [13]: Image.fromarray(test_images[0].astype('uint8')).resize((200,200))
Out[13]:

圖片數(shù)據(jù)集的數(shù)據(jù)結(jié)構(gòu)如下圖所示:

axis0對應(yīng)樣本,axis1對應(yīng)圖片的高,axis2對應(yīng)圖片的寬,axis3對應(yīng)顏色通道。從中抽取一張圖片,則
axis0,axis1,axis2分別對應(yīng)高,寬,通道。
單圖卷積運(yùn)算
#array: 單張圖片數(shù)據(jù)
#kernel: 卷積核
#step: 步長
def conv1(array,kernel,step=1):
#圖片的高和寬
h,w=array.shape[:2]
#卷積核的高和寬
kh,kw=kernel.shape[:2]
#縱向和橫向的移動步數(shù)
hs,ws=(h-kh)//step+1,(w-kw)//step+1
#初始化輸出數(shù)組
out=np.empty((hs,ws))
#縱向滑動卷積核
for i in range(hs):
#卷積核的縱向覆蓋范圍
start0,end0=i*step,i*step+kh
#橫向滑動卷積核
for j in range(ws):
#卷積核的橫向覆蓋范圍
start1,end1=j*step,j*step+kw
#該位置的卷積運(yùn)算
out[i,j]=np.vdot(array[start0:end0,start1:end1],kernel)
return out
In [37]: out=conv1(test_images[0],kernel)
In [38]: out.shape
Out[38]: (30, 30)
In [39]: out_=(out-out.min())/(out.max()-out.min())*255
...: Image.fromarray(out_.astype('uint8')).resize((200,200))
Out[39]:

np.vdot用于將數(shù)組展開為向量后進(jìn)行點積運(yùn)算。
卷積運(yùn)算最為簡單的實現(xiàn)方式是滑窗,用卷積核在每個位置上覆蓋圖像數(shù)據(jù),對應(yīng)元素相乘再求和,存入結(jié)果數(shù)組的相對位置。
單張圖片,單個卷積核,在某一個位置的卷積運(yùn)算如下圖所示:
批量卷積運(yùn)算
#array: 圖片數(shù)據(jù)集
#kernel: 卷積核
#step: 步長
def conv2(array,kernel,step=1):
#圖片的張數(shù)、高、寬
n,h,w=array.shape[:3]
#卷積核的高、寬、顏色通道
kh,kw,kc=kernel.shape[:3]
#縱向和橫向的移動步數(shù)
hs,ws=(h-kh)//step+1,(w-kw)//step+1
#初始化輸出數(shù)組
out=np.empty((n,hs,ws))
#縱向滑動卷積核
for i in range(hs):
#卷積核的縱向覆蓋范圍
start0,end0=i*step,i*step+kh
#橫向滑動卷積核
for j in range(ws):
#卷積核的橫向覆蓋范圍
start1,end1=j*step,j*step+kw
#該位置的卷積運(yùn)算
array_=array[:,start0:end0,start1:end1].reshape((-1,kh*kw*kc))
kernel_=kernel.ravel()
out[:,i,j]=np.dot(array_,kernel_)
return out
In [50]: start=time.clock()
...: out2=conv2(test_images,kernel)
...: print('\ntime used: %f s'%(time.clock()-start))
time used: 2.255798 s
In [51]: out2.shape
Out[51]: (10000, 30, 30)
In [52]: (out==out2[0]).all()
Out[52]: True
np.dot可以用于完成 向量點積,矩陣與向量的乘積,矩陣乘法 等運(yùn)算。
在進(jìn)行某一位置的卷積運(yùn)算時,先在數(shù)據(jù)集上提取卷積核覆蓋范圍的數(shù)據(jù),如上方的例子,可以得到一個形狀為(10000,3,3,3)的張量,第一個軸對應(yīng)每張圖片,后三個軸對應(yīng)卷積核的三個軸。將子數(shù)據(jù)集的后三個軸展開,形狀變?yōu)?code>(10000,27),卷積核展開為向量,形狀變?yōu)?code>(27,),然后進(jìn)行一次矩陣和列向量的乘積運(yùn)算就得到卷積結(jié)果了。
(為方便描述,公式中皆以1為起始索引)
以位置為例,
n,h,w,i,j分別對應(yīng)樣本數(shù),圖片的高,圖片的寬,縱向滑動的步數(shù),橫向滑動的步數(shù),計算的主要過程如下:
2.基于權(quán)重矩陣的卷積運(yùn)算
def conv3(array,kernel,step=1):
#圖片的張數(shù)、高、寬
n,h,w=array.shape[:3]
#卷積核的高、寬、顏色通道
kh,kw,kc=kernel.shape[:3]
#縱向和橫向的移動步數(shù)
hs,ws=(h-kh)//step+1,(w-kw)//step+1
#滑窗卷積運(yùn)算等效的權(quán)重矩陣
weights=np.zeros((h*w*3,hs*ws))
#縱向位置變化
for i in range(hs):
#卷積核的縱向覆蓋范圍
start0,end0=i*step,i*step+kh
#橫向位置變化
for j in range(ws):
#卷積核的橫向覆蓋范圍
start1,end1=j*step,j*step+kw
#當(dāng)前位置的權(quán)重向量
weights_=np.zeros((h,w,3))
weights_[start0:end0,start1:end1]=kernel
#合并至矩陣
weights[:,i*ws+j]=weights_.ravel()
#圖片數(shù)據(jù)集變形
array_=array.reshape((n,h*w*3))
#矩陣乘法計算卷積結(jié)果
result=np.dot(array_,weights)
return result.reshape((-1,hs,ws)),weights
In [18]: start=time.clock()
...: out3,weights3=conv3(test_images,kernel)
...: print('\ntime used: %f s'%(time.clock()-start))
time used: 0.560485 s
In [19]: (out2==out3).all()
Out[19]: True
In [20]: weights.shape
Out[20]: (3072, 900)
In [21]: weights3.size
Out[21]: 2764800
可以看到,在消去循環(huán)體,將原本基于滑窗的卷積運(yùn)算轉(zhuǎn)換為權(quán)重矩陣運(yùn)算后,性能得到了明顯的提升(約3倍)。
但隨之而來的問題就是,構(gòu)造權(quán)重矩陣空間開銷過大,權(quán)重矩陣形狀為(輸入總大小,輸出總大小),即使在低分辨率的cifar數(shù)據(jù)集上,該矩陣也達(dá)到了2764800個元素的大小,隨著圖片分辨率的提高,開銷會增大到無法接受,乃至有性能倒退的可能。
考慮到卷積運(yùn)算的權(quán)重矩陣有大量的零元素,這些元素對計算沒有貢獻(xiàn),應(yīng)當(dāng)采用稀疏矩陣的形式進(jìn)行處理。
對基于滑窗的計算方式進(jìn)行轉(zhuǎn)換的思路,以起始位置為例:
3.稀疏矩陣的定義
列壓縮存儲
In [54]: mt=np.array([[1,2,0,0],[0,4,0,0],[0,0,0,3],[0,0,0,0]])
In [55]: mt
Out[55]:
array([[1, 2, 0, 0],
[0, 4, 0, 0],
[0, 0, 0, 3],
[0, 0, 0, 0]])
In [59]: cscm=sparse.csc_matrix(mt)
In [60]: cscm.data
Out[60]: array([1, 2, 4, 3], dtype=int32)
In [61]: cscm.indices
Out[61]: array([0, 0, 1, 2], dtype=int32)
In [62]: cscm.indptr
Out[62]: array([0, 1, 3, 3, 4], dtype=int32)
In [63]: cscm
Out[63]:
<4x4 sparse matrix of type '<class 'numpy.int32'>'
with 4 stored elements in Compressed Sparse Column format>
稀疏矩陣用于在矩陣中零元素占多數(shù)的情況下減少存儲和運(yùn)算開銷,零元素越多,提升越大。
稀疏矩陣有多種壓縮數(shù)據(jù)的方式,本文中因為會將稀疏矩陣用于右乘,該運(yùn)算下是按列提取向量,故采用列壓縮存儲。
可通過傳入一個稠密矩陣創(chuàng)建對應(yīng)的稀疏矩陣,但在原矩陣構(gòu)造出來會很龐大時不建議這么做,可通過直接構(gòu)造稀疏矩陣的關(guān)鍵參數(shù)來創(chuàng)建。
csc_matrix有三個關(guān)鍵參數(shù):
(1). data參數(shù)是存儲非零元素的值的一維數(shù)組,元素按先列后行排列;
(2). indices參數(shù)是存儲非零元素的列坐標(biāo)的一維數(shù)組,比如一個元素處在某一列的第一個位置,對應(yīng)的列坐標(biāo)就是0;
(3). indptr參數(shù)是存儲每列的首個非零元素在data中位置的一維數(shù)組,再額外加上data的長度;當(dāng)某一列沒有非零元素時,對應(yīng)的indptr元素和上一列相同,如果是第一列則為0。
csc_matrix的toarray和todense方法分別可以將稀疏矩陣還原為稠密矩陣的ndarray形式和matrix形式。
4.基于稀疏矩陣的卷積運(yùn)算
由于先構(gòu)造稠密矩陣再轉(zhuǎn)換為稀疏矩陣的方式與使用稀疏矩陣減少存儲開銷的目的相違背,此處會通過構(gòu)造關(guān)鍵參數(shù)直接創(chuàng)建稀疏矩陣。
data參數(shù)的構(gòu)造較為簡單,權(quán)重矩陣中的非零元素就是卷積核的元素,每個位置卷積運(yùn)算的權(quán)重矩陣會被展開為列向量,csc_matrix中data的元素又是按先列后行排列的,所以只要將卷積核展開成的向量按位置總數(shù)重復(fù)拼接就行了;
indices參數(shù)的構(gòu)造需要了解numpy中元素的排列順序:
首先看起始位置,權(quán)重矩陣展開為列向量后,原本卷積核元素的位置如下所示:

圖上的數(shù)字標(biāo)識的即是展開為向量后元素的位置,numpy數(shù)組上對元素的訪問是從最后一個軸開始的,在RGB圖片形狀
(h,w,c)下,2軸的索引每加1,展開后的索引加1,1軸的索引每加1,展開后的索引加c,0軸索引每加1,展開后的索引加w*c;在橫向移動的過程中,每移動一步,展開后索引會全體增加
step*c;在縱向移動的過程中,每移動一步,展開后索引會全體增加
step*w*c。因此
indices可以分解為三部分:起始位置索引+橫向移動帶來的索引偏移+縱向移動帶來的索引偏移。
indptr參數(shù)的構(gòu)造也很簡單,因為每列只有卷積核元素為非零元素(注意,即使卷積核元素可能為0,也要當(dāng)作非零元素),所以每列首個非零元素在data中的位置可以構(gòu)成以卷積核大小為步長的等差數(shù)列。
def conv4(array,kernel,step=1):
#圖片的張數(shù)、高、寬
n,h,w,c=array.shape
#卷積核的高、寬、顏色通道
kh,kw,kc=kernel.shape
#縱向和橫向的移動步數(shù)
hs,ws=(h-kh)//step+1,(w-kw)//step+1
#1.非零值
data=np.tile(kernel.ravel(),hs*ws)
#2.行索引
#(即卷積核元素在對應(yīng)的權(quán)重矩陣轉(zhuǎn)換成的向量中的位置)
#(1)起始位置索引
#由三個軸方向上的索引偏移量組合得到
idx0=np.arange(0,kh*w*c,w*c).reshape((-1,1,1))
idx1=np.arange(0,kw*c,c).reshape((1,-1,1))
idx2=np.arange(0,c,1).reshape((1,1,-1))
loc_base_=idx0+idx1+idx2
loc_base=np.tile(loc_base_.ravel(),hs*ws)
#(2)橫向和縱向移動的索引偏移
loc_offset0=np.arange(0,hs*step*w*c,step*w*c).reshape((-1,1))
loc_offset1=np.arange(0,ws*step*c,step*c).reshape((1,-1))
loc_offset_=loc_offset0+loc_offset1
loc_offset=loc_offset_.repeat(kh*kw*kc)
indices=loc_base+loc_offset
#3.列偏移
#(即每列第一個非零值在values中的位置)
indptr=np.arange(hs*ws+1)*(kh*kw*kc)
#構(gòu)造稀疏矩陣
weights=sparse.csc_matrix((data,indices,indptr))
#圖片數(shù)據(jù)集變形
array_=array.reshape((n,h*w*3))
#稀疏矩陣乘法計算卷積結(jié)果
result=array_*weights
return result.reshape((-1,hs,ws)),weights
In [77]: start=time.clock()
...: out4,weights4=conv4(test_images,kernel)
...: print('\ntime used: %f s'%(time.clock()-start))
time used: 0.728124 s
In [77]: (out2==out4).all()
Out[77]: True
In [86]: weights4.data.size+weights4.indices.size+weights4.indptr.size
Out[86]: 49501
可以看到,雖然性能上稍有折損,但關(guān)鍵的內(nèi)存開銷降下來了,權(quán)重矩陣從原本的輸入大小*輸出大小個元素,減少到卷積核大小*輸出大小*2+輸出大小+1個元素。
在模型訓(xùn)練完畢后,可將所有卷積核轉(zhuǎn)換為稀疏矩陣存儲,避免每次前向傳播都要重新構(gòu)造。