Keras中自定義復雜的loss函數
By 蘇劍林 | 2017-07-22 | 92497位讀者 |Keras是一個搭積木式的深度學習框架,用它可以很方便且直觀地搭建一些常見的深度學習模型。在tensorflow出來之前,Keras就已經幾乎是當時最火的深度學習框架,以theano為后端,而如今Keras已經同時支持四種后端:theano、tensorflow、cntk、mxnet(前三種官方支持,mxnet還沒整合到官方中),由此可見Keras的魅力。
Keras是很方便,然而這種方便不是沒有代價的,最為人詬病之一的缺點就是靈活性較低,難以搭建一些復雜的模型。的確,Keras確實不是很適合搭建復雜的模型,但並非沒有可能,而是搭建太復雜的模型所用的代碼量,跟直接用tensorflow寫也差不了多少。但不管怎么說,Keras其友好、方便的特性(比如那可愛的訓練進度條),使得我們總有使用它的場景。這樣,如何更靈活地定制Keras模型,就成為一個值得研究的課題了。這篇文章我們來關心自定義loss。
輸入-輸出設計 #
Keras的模型是函數式的,即有輸入,也有輸出,而loss即為預測值與真實值的某種誤差函數。Keras本身也自帶了很多loss函數,如mse、交叉熵等,直接調用即可。而要自定義loss,最自然的方法就是仿照Keras自帶的loss進行改寫。
比如,我們做分類問題時,經常用的就是softmax輸出,然后用交叉熵作為loss。然而這種做法也有不少缺點,其中之一就是分類太自信,哪怕輸入噪音,分類的結果也幾乎是非1即0,這通常會導致過擬合的風險,還會使得我們在實際應用中沒法很好地確定置信區間、設置閾值。因此很多時候我們也會想辦法使得分類別太自信,而修改loss也是手段之一。
如果不修改loss,我們就是使用交叉熵去擬合一個one hot的分布。交叉熵的公式是
其中 pipi是預測的分布,而qiqi是真實的分布,比如輸出為[z1,z2,z3][z1,z2,z3],目標為[1,0,0][1,0,0],那么
只要z1z1已經是[z1,z2,z3][z1,z2,z3]的最大值,那么我們總可以“變本加厲”——通過增大訓練參數,使得z1,z2,z3z1,z2,z3增加足夠大的比例(等價地,即增大向量[z1,z2,z3][z1,z2,z3]的模長),從而ez1/Zez1/Z足夠接近1(等價地,loss足夠接近0)。這就是通常softmax過於自信的來源:只要盲目增大模長,就可以降低loss,訓練器肯定是很樂意了,這代價太低了。為了使得分類不至於太自信,一個方案就是不要單純地去擬合one hot分布,分一點力氣去擬合一下均勻分布,即改為新loss:
這樣,盲目地增大比例使得ez1/Zez1/Z接近於1,就不再是最優解了,從而可以緩解softmax過於自信的情況,不少情況下,這種策略還可以增加測試准確率(防止過擬合)。
那么,在Keras中應該怎么寫呢?其實挺簡單的:
from keras.layers import Input,Embedding,LSTM,Dense from keras.models import Model from keras import backend as K word_size = 128 nb_features = 10000 nb_classes = 10 encode_size = 64 input = Input(shape=(None,)) embedded = Embedding(nb_features,word_size)(input) encoder = LSTM(encode_size)(embedded) predict = Dense(nb_classes, activation='softmax')(encoder) def mycrossentropy(y_true, y_pred, e=0.1): loss1 = K.categorical_crossentropy(y_true, y_pred) loss2 = K.categorical_crossentropy(K.ones_like(y_pred)/nb_classes, y_pred) return (1-e)*loss1 + e*loss2 model = Model(inputs=input, outputs=predict) model.compile(optimizer='adam', loss=mycrossentropy)
也就是自定義一個輸入為y_pred,y_true的loss函數,放進模型compile即可。這里的mycrossentropy,第一項就是普通的交叉熵,第二項中,先通過K.ones_like(y_pred)/nb_classes構造了一個均勻分布,然后算y_pred與均勻分布的交叉熵。就這么簡單~
並不僅僅是輸入輸出那么簡單 #
前面已經說了,Keras的模型有固定的輸入和輸出,並且loss即為預測值與真實值的某種誤差函數,然而,很多模型並非這樣的,比如問答模型與triplet loss。
這個的問題是指有固定的答案庫的FAQ形式的問答。一種常見的做問答模型的方法就是:先分別將答案和問題都encode成為一個同樣長度的向量,然后比較它們的\cos值,\cos越大就越匹配。這種做法很容易理解,是一個比較通用的框架,比如這里的問題和答案都不需要一定是問題,圖片也行,反正只不過是encode的方法不一樣,最終只要能encode出一個向量來即可。但是怎么訓練呢?我們當然希望正確答案的\cos值越大越好,錯誤答案的\cos值越小越好,但是這不是必要的,合理的要求應該是:正確答案的\cos值比所有錯誤答案的\cos值都要大,大多少無所謂,一丁點都行。因此,這就導致了triplet loss:
其中mm是一個大於零的正數。
怎么理解這個loss呢?要注意我們要最小化loss,所以只看m+cos(q,Awrong)−cos(q,Aright)m+cos(q,Awrong)−cos(q,Aright)這部分,我們知道目的是拉大正確與錯誤答案的差距,但是,一旦cos(q,Aright)−cos(q,Awrong)>mcos(q,Aright)−cos(q,Awrong)>m,也就是差距大於mm時,由於maxmax的存在,loss就等於0,這時候就自動達到最小值,就不會優化它了。所以,triplet loss的思想就是:只希望正確比錯誤答案的差距大一點(並不是越大越好),超過mm就別管它了,集中精力關心那些還沒有拉開的樣本吧!
我們已經有問題和正確答案,錯誤答案只要隨機挑就行,所以這樣訓練樣本是很容易構造的。不過Keras中怎么實現triplet loss呢?看上去是一個單輸入、雙輸出的模型,但並不是那么簡單,Keras中的雙輸出模型,只能給每個輸出分別設置一個loss,然后加權求和,但這里不能簡單表示成兩項的加權求和。那應該要怎么搭建這樣的模型呢?下面是一個例子:
from keras.layers import Input,Embedding,LSTM,Dense,Lambda from keras.layers.merge import dot from keras.models import Model from keras import backend as K word_size = 128 nb_features = 10000 nb_classes = 10 encode_size = 64 margin = 0.1 embedding = Embedding(nb_features,word_size) lstm_encoder = LSTM(encode_size) def encode(input): return lstm_encoder(embedding(input)) q_input = Input(shape=(None,)) a_right = Input(shape=(None,)) a_wrong = Input(shape=(None,)) q_encoded = encode(q_input) a_right_encoded = encode(a_right) a_wrong_encoded = encode(a_wrong) q_encoded = Dense(encode_size)(q_encoded) #一般的做法是,直接講問題和答案用同樣的方法encode成向量后直接匹配,但我認為這是不合理的,我認為至少經過某個變換。 right_cos = dot([q_encoded,a_right_encoded], -1, normalize=True) wrong_cos = dot([q_encoded,a_wrong_encoded], -1, normalize=True) loss = Lambda(lambda x: K.relu(margin+x[0]-x[1]))([wrong_cos,right_cos]) model_train = Model(inputs=[q_input,a_right,a_wrong], outputs=loss) model_q_encoder = Model(inputs=q_input, outputs=q_encoded) model_a_encoder = Model(inputs=a_right, outputs=a_right_encoded) model_train.compile(optimizer='adam', loss=lambda y_true,y_pred: y_pred) model_q_encoder.compile(optimizer='adam', loss='mse') model_a_encoder.compile(optimizer='adam', loss='mse') model_train.fit([q,a1,a2], y, epochs=10) #其中q,a1,a2分別是問題、正確答案、錯誤答案的batch,y是任意形狀為(len(q),1)的矩陣
如果第一次看不懂,那么請反復閱讀幾次,這個代碼包含了Keras中實現最一般模型的思路:把目標當成一個輸入,構成多輸入模型,把loss寫成一個層,作為最后的輸出,搭建模型的時候,就只需要將模型的output定義為loss,而compile的時候,直接將loss設置為y_pred(因為模型的輸出就是loss,所以y_pred就是loss),無視y_true,訓練的時候,y_true隨便扔一個符合形狀的數組進去就行了。最后我們得到的是問題和答案的編碼器,也就是問題和答案都分別編碼出一個向量來,我們只需要比較\cos,就可以選擇最優答案了。
Embedding層的妙用 #
在讀這一段之前,請讀者務必確定自己對Embedding層有清晰的認識,如果還沒有,請移步閱讀《詞向量與Embedding究竟是怎么回事?》。這里需要反復強調的是,雖然詞向量叫Word Embedding,但是,Embedding層不是詞向量,跟詞向量沒有半毛錢關系!!!不要有“怎么就跟詞向量扯上關系了”這樣的傻問題,Embedding層從來就沒有跟詞向量有過任何直接聯系(只不過在訓練詞向量時可以用它)。對於Embedding層,你可以有兩種理解:1、是one hot輸入的全連接層的加速版本,也就是說,它就是一個以one hot為輸入的Dense層,數學上完全等價;2、它就是一個矩陣查找操作,輸入一個整數,輸出對應下標的向量,只不過這個矩陣是可訓練的。(你看,哪里跟詞向量有聯系了?)
這部分我們來關心center loss。前面已經說了,做分類時,一般是softmax+交叉熵做,用矩陣的寫法,softmax就是
其中xx可以理解為提取的特征,而W,bW,b是最后的全連接層的權重,整個模型是一起訓練的。問題是,這樣的方案所訓練出來的特征模型xx,具有怎樣的形態呢?
有一些情況下,我們更關心特征xx而不是最后的分類結果,比如人臉識別場景,假如我們有10萬個不同的人的人臉數據庫,每個人有若干張照片,那么我們就可以訓練一個10萬分類模型,對於給定的照片,我們可以判斷它是10萬個中的哪一個。但這僅僅是訓練場景,那么怎么應用呢?到了具體的應用環境,比如一個公司內部,可能有只有幾百人;在公共安全檢測場景,可能有數百萬人,所以前面做好的10萬分類模型基本上是沒有意義的,但是在這個模型softmax之前的特征,也就是前一段所說的xx,可能還是很有意義的。如果對於同一個人(也就是同一類),xx基本一樣,那么實際應用中,我們就可以把訓練好的模型當作特征提取工具,然后把提取出來的特征直接用KNN(最鄰近距離)來做就行了。
設想很美好,但事實很殘酷,直接訓練softmax的話,事實上得到的特征不一定具有聚類特性,相反,它們會盡量布滿整個空間(沒有給其他人留出位置,參考center loss的相關論文和文章,比如這篇。)。那么,怎樣訓練才使得結果有聚類特性呢?center loss使用了一種簡單粗暴但是卻很有效的方案——加聚類懲罰項。完整地寫出來,就是
其中yy對應着正確的類別。可以看到,第一項就是普通的softmax交叉熵,第二項就是額外的懲罰項,它給每個類定義了可訓練的中心cc,要求每個類要跟各自的中心靠得很近。所以,總的來說,第一項負責拉開不同類之間的距離,第二項負責縮小同一類之間的距離。
那么,Keras中要怎么實現這個方案?關鍵是,怎么存放聚類中心?答案就是Embedding層!這部分的開頭已經提示了,Embedding就是一個待訓練的矩陣罷了,正好可以存放聚類中心參數。於是,模仿第二部分的寫法,就得到
from keras.layers import Input,Conv2D, MaxPooling2D,Flatten,Dense,Embedding,Lambda from keras.models import Model from keras import backend as K nb_classes = 100 feature_size = 32 input_image = Input(shape=(224,224,3)) cnn = Conv2D(10, (2,2))(input_image) cnn = MaxPooling2D((2,2))(cnn) cnn = Flatten()(cnn) feature = Dense(feature_size, activation='relu')(cnn) predict = Dense(nb_classes, activation='softmax', name='softmax')(feature) #至此,得到一個常規的softmax分類模型 input_target = Input(shape=(1,)) centers = Embedding(nb_classes, feature_size)(input_target) #Embedding層用來存放中心 l2_loss = Lambda(lambda x: K.sum(K.square(x[0]-x[1][:,0]), 1, keepdims=True), name='l2_loss')([feature,centers]) model_train = Model(inputs=[input_image,input_target], outputs=[predict,l2_loss]) model_train.compile(optimizer='adam', loss=['sparse_categorical_crossentropy',lambda y_true,y_pred: y_pred], loss_weights=[1.,0.2], metrics={'softmax':'accuracy'}) model_predict = Model(inputs=input_image, outputs=predict) model_predict.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy']) model_train.fit([train_images,train_targets], [train_targets,random_y], epochs=10) #TIPS:這里用的是sparse交叉熵,這樣我們直接輸入整數的類別編號作為目標,而不用轉成one hot形式。所以Embedding層的輸入,跟softmax的目標,都是train_targets,都是類別編號,而random_y是任意形狀為(len(train_images),1)的矩陣。
讀者可能有疑問,為什么不像第二部分的triplet loss模型那樣,將整體的loss寫成一個單一的輸出,然后搭建模型,而是要像目前這樣變成雙輸出呢?
事實上,Keras愛好者鍾情於Keras,其中一個很重要的原因就是它的進度條——能夠實時顯示訓練loss、訓練准確率。如果像第二部分那樣寫,那么就不能設置metrics參數,那么訓練過程中就不能顯示准確率了,這不能說是一個小遺憾。而目前這樣寫,我們就依然能夠在訓練過程中看到訓練准確率,還能分別看到交叉熵loss、l2_loss、總的loss分別是多少,非常舒服。
Keras就是這么好玩 #
有了以上三個案例,讀者應該對Keras搭建復雜模型的步驟心中有數了,應當說,也是比較簡單靈活的。Keras確實有它不夠靈活的地方,但也沒有網上評論的那么無能。總的來說,Keras是能夠滿足大多數人快速實驗深度學習模型的需求的。如果你還在糾結深度學習框架的選擇,那么請選擇Keras吧——當你真正覺得Keras不能滿足你的需求時,你已經有能力駕馭任何框架了,也就沒有這個糾結了。