前沿:第一次在簡書上留下印跡。畢業(yè)后的第一篇文章,貢獻給了簡書圆裕,只因被它的簡約風格所吸引陋桂。今天我分享的主題是"CNN如何應用在句子分類任務上"逆趣。我主要從思路和源碼的角度進行分析。
一嗜历、 任務介紹
句子分類任務宣渗,主要是將某個query句子,進行類別的劃分梨州;例如美食類痕囱、娛樂類、音樂類暴匠、學習類鞍恢、旅游類等多種類別。本文,主要介紹如何構(gòu)建出一個end2end的深度學習模型帮掉,不需要人為手工進行特征工程的設計弦悉,以期達到90%+精度的句子分類需求。
源碼Github鏈接 https://github.com/yzx1992/sentence_classification (順便幫忙start一下)
源碼基于Tensorflow1.1版本蟆炊。 只展示了部分Demo數(shù)據(jù)集合稽莉,還望讀者理解(建議:自己構(gòu)建爬蟲代碼,自給自足最靠譜)涩搓。
train set: 1000條
test set: 500條
二污秆、設計思路
由于句子分類任務中,"類別類型"只對句子中某個字詞或幾個字詞較為敏感缩膝。例如:"我們?nèi)コ院5讚瓢桑? 若模型能夠捕捉到"海底撈"這三個字的特征信息混狠,那么毫無疑問,句子將應該被分到美食這個類別上疾层。
因此将饺,我們可以先將句子切成 "字" 級別或 "詞級別"(python中可以采用jieba包進行切詞)。值得說明的是痛黎,若訓練集較小予弧,"詞"級別的效果會更好一些;另外湖饱,添加pre-train的embbeding效果會有幾個點的提升掖蛤。然而,詞級別所對應的詞向量矩陣較大井厌,參數(shù)較多蚓庭,最終訓練得到的模型參數(shù)較大(即,字典的size較大仅仆,所需要訓練的embbed_matrix 參數(shù)較多器赞,很容易擠爆顯存,出行OOM的情況墓拜。例如港柜,中文高頻詞字典取10w ,word_emb的維度取200維,單單一個embbed_matrix的參數(shù)就有10w*200=2000w個參數(shù)咳榜,2000w *4個Byte(float浮點型占4個字節(jié))約等于80M)夏醉。 實際我跑的句子分類任務中,詞模型的參數(shù)大小在300M左右涌韩,字模型則可以控制在10M左右畔柔。我當時做任務的訓練語料達100W+, 因此,詞模型和字模型臣樱,最終精確率释树、召回率肠槽、F1等各項指標的表現(xiàn)差別不大。
下文奢啥,主要以"字"模型來進行分析秸仙。
采用CNN框架,利用不同size的卷積核(kernel)來捕捉句子的ngram特征(1個字桩盲,2個字寂纪,3個字,4個字等)赌结,值得注意的是捞蛋,相同size的卷積核,通常會包含多個柬姚,通過不同初始化卷積核的參數(shù)拟杉,來達到多維度獲取句子ngram的語意信息。實驗中我設置為100量承,即num_filtes=100搬设。此外,大部分分類任務采用較粗粒度的特征就可以搞定(當然撕捍,取決于你類別體系的粒度拿穴,可以分為一級標簽,二級標簽忧风,三級標簽等等)默色,為了降低模型參數(shù),可以采用max pooling的方式狮腿,來保留ngram中最明顯的特征腿宰;例如對于"我們?nèi)コ院5讚瓢桑? 3-gram的信息包含"0 0 我"、"0 我 們"缘厢、"我們?nèi)?酗失、"們 去 吃"、"去吃海"昧绣、"吃 海 底"、"海 底 撈 " …… 等等捶闸,對于3-gram的所有特征夜畴,我們當然希望模型學到只留下"海 底 撈"一個就好。
最終删壮,對于單個query贪绘,我們總共抽取了100個1-gram, 100個2-gram, 100個3-gram,100個4-gram特征央碟,即400個特征税灌。
通常,這時候你可以接多層全連接,一般取1~3層菱涤,每層可接tanh或者relu等激活函數(shù)苞也,來達到非線性的效果(如果沒添加非線性的激活函數(shù),多層全連接等價于單層粘秆,數(shù)學上如迟,矩陣求解可知--> W2 (W1 X))=WX (其中,X表示特征向量攻走,W表示參數(shù)向量)
最后殷勘,映射到9維的向量空間,即得到每個類別對應的score得分昔搂,再經(jīng)過softmax函數(shù)作用之后玲销,將各個類別的score得分轉(zhuǎn)化為歸一化概率分布(即,各個類別的概率值相加等于1)
三摘符、走進代碼
1)首先看data.py 數(shù)據(jù)處理部分:
采用迭代器的設計思路贤斜,每次從文件讀取mini_batch個訓練樣本。迭代器設計的優(yōu)勢在于议慰,每次只讀取mini_batch個句子到內(nèi)存中蠢古,而不是把所有訓練集一股腦扔到內(nèi)存。對于那種千萬級個sample以上的文件别凹,采用迭代器草讶,可大大降低內(nèi)存的開銷,提高執(zhí)行效率炉菲。
def BatchIter(data_path, batch_size) 函數(shù):
data_path: train set 或 valid set的路徑
batch_size: 模型梯度的更新參用mini_batch進行堕战,防止單個異常樣本,梯度波動太大拍霜。
def BatchIter(data_path, batch_size):
0 #print(data_path)
1 with open(data_path, 'r') as f:
2 sample_num = 0
3 samples = []
4 for line in f:
5 line = line.strip()
6 samples.append(line)
7 sample_num += 1
8 if sample_num == batch_size:
9 a = zip(*[s.split("\t") for s in samples])
10 l = list(a[0])
11 x = [s.strip() for s in a[1]]
12 x = np.array(text2list(x))
13 y = []
14 for i in l:
15 y.append([0]*9)
16 y[len(y)-1][int(i)-1] = 1
17 batch_data = list(zip(x, y))
18 batch_data = np.array(batch_data)
19 yield batch_data
20 sample_num = 0
21 samples = []
其中嘱丢,第9~12行代碼:
a = zip(*[s.split("\t") for s in samples]) 表示合并batch_size個樣本。
單個樣本對構(gòu)造為: label_id \t word_id1 word_id2 word_id3 …… 即祠饺,句子所屬標簽越驻,句子以單字表示對應的id序列,兩者以\t分隔符進行分割道偷。
eg:
case1: 4 32 33 1571 20 57 58 0 0
case2: 1 108 287 916 917 101 572 0 0
case3: 2 318 319 95 646 647 319 192 95
即缀旁,max_len=8, batch_size=3
此時,值對應如下:
a=[('4', '1', '2'), ('32 33 1571 20 57 58 0 0', '108 287 916 917 101 572 0 0', '318 319 95 646 647 319 192 95')]
l=['4', '1', '2']
x=['32 33 1571 20 57 58 0 0', '108 287 916 917 101 572 0 0', '318 319 95 646 647 319 192 95']
x=[[ 32 33 1571 20 57 58 0 0]
[ 108 287 916 917 101 572 0 0]
[ 318 319 95 646 647 319 192 95]]
第13~16行代碼: 本份代碼只針對9個類別進行闡述勺鸦,若你任務的分類類別有x種并巍,則把9改成x。
這幾句代碼换途,對batch個句子的label標簽構(gòu)造成one-hot向量懊渡。方便后續(xù)求預測lable和真實label的交叉熵刽射,即損失函數(shù)Loss。
例如:case1的標簽是4, 即對于9維度的向量剃执,其第3個位置置1誓禁,其他位置置0,(位置索引是從index=0開始的)忠蝗,即现横, case1對應的ground_true的向量標簽為 [0, 0, 0, 1, 0, 0, 0, 0, 0] 。同理阁最,case2的標簽是1戒祠,對應[1, 0, 0, 0, 0, 0, 0, 0, 0] ,case3的標簽是2速种,則對應 [0, 1, 0, 0, 0, 0, 0, 0, 0]姜盈。
對于,case1來說,當你經(jīng)過最后一層全連接的輸出配阵,得到一個維度為9的向量馏颂,即對應各個類別的score得分。取argmax后棋傍,該值即對應最終句子分類的label id救拉,例如對于case1來說,argmax(9維向量的概率分布 or 9維向量的score得分)=2瘫拣,表示case1最后預測的score向量為[y0, y1, y2亿絮,y3, y4, y5, y6, y7, y8]。這時候預測與真實值就有了誤差麸拄,采用交叉熵的方式求解其對應的誤差表征派昧,最后采用梯度下降算法(即反向傳播back-propagation algorithm),來更新參數(shù)拢切,使得預測值更加靠近ground True的label值蒂萎。
y=[[0, 0, 0, 1, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0, 0, 0, 0], [0, 1, 0, 0, 0, 0, 0, 0, 0]]
第17行~19行,對x和y進行zip淮椰,方便訓練樣本與label標簽一一對應五慈,最后再轉(zhuǎn)成numpy數(shù)組的形式,準備訓練的時候主穗,feed給模型泻拦。
batch_data=[(array([ 32, 33, 1571, 20, 57, 58, 0, 0]), [0, 0, 0, 1, 0, 0, 0, 0, 0]),
(array([108, 287, 916, 917, 101, 572, 0, 0]), [1, 0, 0, 0, 0, 0, 0, 0, 0]), (array([318, 319, 95, 646, 647, 319, 192, 95]), [0, 1, 0, 0, 0, 0, 0, 0, 0])]
batch_data=[[array([ 32, 33, 1571, 20, 57, 58, 0, 0]) list([0, 0, 0, 1, 0, 0, 0, 0, 0])]
[array([108, 287, 916, 917, 101, 572, 0, 0]) list([1, 0, 0, 0, 0, 0, 0, 0, 0])]
[array([318, 319, 95, 646, 647, 319, 192, 95]) list([0, 1, 0, 0, 0, 0, 0, 0, 0])]]
yield 是一個類似return的關鍵字,只不過其返回的是個生成器黔牵,不是具體的值。
在train_multi_gpu.py腳本中爷肝,Train()則可以方便地調(diào)用data.py函數(shù)猾浦,從而得到batch個訓練樣本與其所對應的label值
train_iter = BatchIter(train_path, batch_size) #返回可迭代對象
for train_batch in train_iter:
x_batch, y_batch = zip(*train_batch) #返回batch個數(shù)據(jù)
2)直接進入模型代碼陆错,轉(zhuǎn)移到text_cnn.py 這個函數(shù)。
def inference(input_x, input_y, sequence_length,
vocab_size, embedding_size, filter_sizes, num_filters,
x_size, cpus, l2_reg_lambda=0.0, dropout_keep_prob=0.5)
input_x: 表示batch個輸入序列的字id金赦,shape=[batch,max_len] batch表示mini_batch的大小音瓷,max_len,表現(xiàn)句子的最大長度夹抗,(大于max_len的句子截斷绳慎,小于max_len的句子補0)
input_y: 表示batch個ground True label的向量(如上所述,向量維度為9漠烧,總共分為9個類別) 杏愤,shape=[batch,9]
embedding_size: 即,每個字用多少維向量來表示已脓,通常維度取100~300即可珊楼。
filter_sizes:[2,3,4,5],表示卷積核的大小(kernel_size)取寬度=embbedding_size度液,高度分別取2厕宗,3,4堕担,5已慢。即捕獲句子2-gram,3-gram,4-gram,5-gram的特征。這里大家可能會有疑惑霹购,為什么寬度一定要取embbeding_size佑惠,而不能像圖像(CV)一樣,來個33或者55的卷積核厕鹃? 對于句子而言兢仰,我們最主要的是想捕捉n-gram的特征,例如捕獲句子"我們?nèi)コ院5讚瓢桑?剂碴,我們主要想捕獲3-gram的特征就OK了把将。如果卷積核的寬度不取embbeding_size的維度,則會破壞字向量的語意信息忆矛,自然也就談不上ngram特征了袭艺。
num_filters: 本文設置為100屁置,即不同kernel_size的卷積核,取100個。通過不同初始化參數(shù)避凝,來達到多角度,更加全面的獲取相對應ngram的信息避消。
x_size:暫時沒用到下翎,略。
l2_reg_lambda: L2,正則化懲罰系數(shù)采驻。目的审胚,防止模型因參數(shù)過于復雜:出現(xiàn)過擬合現(xiàn)象匈勋。通常,會在損失函數(shù)上膳叨,加上參數(shù)的懲罰洽洁,本文暫時取0,可根據(jù)模型效果菲嘴,自行調(diào)(lian)參 (dan)饿自。
dropout_keep_prob:本文取0.5,讓每個神經(jīng)元以50%的概率不工作龄坪,即處于睡眠狀態(tài)昭雌,不進行前向score傳播,也不進行反向error傳遞悉默。 目的:減少神經(jīng)元之間復雜的共適應性城豁,提高模型的泛化能力。
import tensorflow as tf
TOWER_NAME = 'CNN'
def _variable_on_cpu(name, shape, initializer, cpus):
with tf.device('/gpu:6' ):
var = tf.get_variable(name, shape, initializer=initializer)
return var
def inference(input_x, input_y, sequence_length,
vocab_size, embedding_size, filter_sizes, num_filters,
x_size, cpus, l2_reg_lambda=0.0, dropout_keep_prob=0.5):
0 l2_loss = tf.constant(0.0)
1 with tf.variable_scope("embedding") as scope:
2 W = _variable_on_cpu("W", [vocab_size+22, embedding_size],
3 tf.random_uniform_initializer(-1.0, 1.0), cpus)
4 #print vocab_size,input_x
5 embedded_chars = tf.nn.embedding_lookup(W, input_x)
6 embedded_chars_expanded = tf.expand_dims(embedded_chars, -1)
7 pooled_outputs = []
8 for i, filter_size in enumerate(filter_sizes):
9 with tf.variable_scope("conv-maxpool-%s" % filter_size) as scope :
10 # Convolution Layer
11 filter_shape = [filter_size, embedding_size, 1, num_filters]
12 W = _variable_on_cpu("W", filter_shape, tf.truncated_normal_initializer(stddev=0.1), cpus)
13 b = _variable_on_cpu('b', [num_filters],tf.constant_initializer(0.1), cpus)
14 conv = tf.nn.conv2d(embedded_chars_expanded,
15 W,
16 strides=[1, 1, 1, 1],
17 padding="VALID",
18 name="conv")
19
20 # Apply nonlinearity
21 h = tf.nn.relu(tf.nn.bias_add(conv, b), name="relu")
22 # Maxpooling over the outputs
23 pooled = tf.nn.max_pool(h,
24 ksize=[1, sequence_length - filter_size + 1, 1, 1],
25 strides=[1, 1, 1, 1],
26 padding='VALID',
27 name="pool")
28
29 pooled_outputs.append(pooled)
30 # Combine all the pooled features
31 with tf.variable_scope("combine") as scope :
32 num_filters_total = num_filters * len(filter_sizes)
33 h_pool = tf.concat( pooled_outputs,3)
34 h_pool_flat = tf.reshape(h_pool, [-1, num_filters_total], name="encode")
35 # Add dropout
36 with tf.variable_scope("dropout") as scope :
37 h_drop = tf.nn.dropout(h_pool_flat, dropout_keep_prob, name="h_drop")
38
39 # 4 Hidden layer to map all the pooled features
40 with tf.variable_scope("output") as scope:
41 W = _variable_on_cpu("W1", [num_filters_total, 9], tf.truncated_normal_initializer(stddev=0.1), cpus)
42 b = _variable_on_cpu('b1', [9], tf.constant_initializer(0.1), cpus)
43 l2_loss += tf.nn.l2_loss(W)
44 l2_loss += tf.nn.l2_loss(b)
45 scores = tf.nn.xw_plus_b(h_drop, W, b, name='scores')
46 probs = tf.nn.softmax(scores, name='probs')
47 predictions = tf.argmax(scores, 1, name = 'predictions')
48
49 # CalculateMean cross-entropy loss
50 with tf.variable_scope("loss") as scope :
51 losses = tf.nn.softmax_cross_entropy_with_logits(labels=input_y,logits=scores)
52 loss = tf.reduce_mean(losses) + l2_reg_lambda*l2_loss
53
54 # Accuracy
55 with tf.name_scope("accuracy"):
56 correct = tf.equal(predictions, tf.argmax(input_y, 1))
57 accuracy = tf.reduce_mean(tf.cast(correct, "float"), name="accuracy")
58 return loss, accuracy
代碼第0~6行:
W:word_embbeding matrix shape=[vocab_size+22, embedding_size] , 其中抄课,22可以隨便換個正整數(shù)唱星,防止id索引word_embbeding matrix越界。
embbed_chars: batch個句子的詞向量表征跟磨。 shape=[batch, max_len, embedding_size]
embedded_chars_expanded:shape=[batch, max_len, embedding_size,1] 间聊,在最末尾增加一個維度,因為TF的卷積操作抵拘,要求參數(shù)必須是4維哎榴。
代碼第8~29行:
第8行:對每個不同size的卷積核(kernel)進行遍歷,這里filter_size=2,3,4,5僵蛛,這里以filter_size=3 尚蝌、max_len=50 、num_filters=100 來進行分析充尉。
filter_shape: 卷積核的維度 shape=[3,embedding_size,1,100]
w: 卷積核參數(shù) shape=[3,embedding_size,1,100] ,采用標準差為0.1的正太分布進行參數(shù)初始化飘言。
b: 偏置參數(shù), shape=[100]驼侠,初始化為0.1
tf.nn.conv2d(input,filters,strides,padding)
input: shape=[batch, max_len, embedding_size,1] 以圖像進行類比:batch_size姿鸿,對應圖片的數(shù)量,可以暫時忽略這個維度倒源。max_len苛预, 類比圖像的高度,embedding_size笋熬,類比圖像的寬度热某,1,類比圖像的通道數(shù)(1表示單通道的灰色圖像)
filters: shape=[3,embedding_size,1,100] 3代表卷積核的高度,embedding_siez表示卷積核的寬度昔馋,1芜繁,類比圖像的通道數(shù),100表征卷積核的數(shù)目绒极。
padding: string類型,值為“SAME” 和 “VALID”蔬捷,表示的是卷積的形式垄提,是否考慮邊界≈芄眨”SAME”是考慮邊界铡俐,不足的時候用0去填充周圍,”VALID”則不考慮.
最終conv: shape=[batch, max_len-filter_size+1, 1, 100] = [batch, 100-3+1, 1, 100]
經(jīng)過一層relu非線性函數(shù)激活后妥粟,再進行max pooling
此時审丘,pooled:shape=[batch,1,100]
因為pooled_outputs添加了4種不同size卷積核的特征輸出,即pooled_outputs=[[batch,1,100] ,[batch,1,100] ,[batch,1,100] ,[batch,1,100] ]
代碼第31~37行:
首先勾给,對pooled_outputs進行最后一個維度的拼接滩报。即h_pool:shape=[batch,1,100*4]
然后,在flatten播急,得到h_pool_flat: shape=[batch,400]
再進行dropout操作脓钾,得到最終句子的特征。
代碼第40~57:
采用單層全連接桩警,將CNN捕獲到的400維特征向量可训,映射到9個類別, 得到每個類別的得分。
值得注意的是捶枢,如果訓練語料足夠多握截,大50w以上,可以嘗試3層的全連接層烂叔,增大模型的容量谨胞,泛化效果可以更好一些。
此時长已,scores: shape=[batch,9] 9個類別
進行softmax操作畜眨,將9個類別的socre得分映射成概率分布。
probs: shape=[batch,9] 术瓮,9個類別的概率值相加=1
predictions即為最終預測label的id shape=[batch,]
采用交叉熵來作為損失函數(shù)LOSS
值得注意的是:tf.nn.softmax_cross_entropy_with_logits(labels,logits) 交叉熵計算函數(shù)輸入中的logits并不是softmax或sigmoid的輸出康聂,而是未經(jīng)過非線性函數(shù)前的得分scores,因為該函數(shù)內(nèi)部會對score進行sigmoid或softmax操作胞四。
此時losses shape=[batch,9]
進行最后一個維度的求和恬汁,即,累加9個類別的損失辜伟。loss shape=[batch,]
correct 返回True,False列表 shape=[batch,]
accuracy 返回準確率 shape=[batch,]
OK氓侧,不出意外的話脊另,你的模型應該順利run起來了! 記得GPU訓練约巷,否則你得等到猴年馬月偎痛!
不過,你可以縮小batch的值独郎,調(diào)整訓練集的大小踩麦,在本地電腦run看看代碼是否有bug,或者修改模型框架氓癌,封裝成自己看起來順眼的API 谓谦!
-
測試模型效果,目光移步到evaluation.py 腳本贪婉。
記得反粥,修改好數(shù)據(jù)路徑和模型路徑。
對于一個query疲迂,可以根據(jù)top函數(shù)才顿,輸出top 1或者top3的指標,分別包含recall_rate尤蒿、precision_rate娜膘、F1指標。
加載模型你可saver=tf.train.import_meta_graph(FLAGS.model_dir+'/model.ckpt-24.meta') sess.graph.get_tensor_by_name('dev_x:0') #根據(jù)tensor的名字加載變量
也可以采用
ckpt = tf.train.get_checkpoint_state(checkpoint_path)
if ckpt and tf.train.checkpoint_exists(ckpt.model_checkpoint_path):
print("Reloading model parameters..")
_model.saver.restore(sess=session, save_path=ckpt.model_checkpoint_path)
四优质、總結(jié)
一定要統(tǒng)計好數(shù)據(jù)分布竣贪,即各個類別的數(shù)據(jù)分布應該保持在相同數(shù)量級別。否則巩螃,模型容易 "劍走偏鋒"演怎,一直傾向于輸出某個類別。 數(shù)據(jù)才是決定最終的指標的上限1芊ΑR!
對于類別體系較為復雜的分類拍皮,需要捕捉更加細粒度的特征歹叮,直接分詞+max pooling 效果可能會欠佳∶保可以拼接"字"特征+ "詞"特征咆耿,最好也引入ner(實體特征)等多維度信息。
3)對于短文本爹橱,也可以引入Bi-LSTM 直接捕獲整個句子的信息萨螺,直接暴力采用最后一個step的隱藏層即可,(拼接前向隱藏層和后向隱藏層),注意一定要mask掉padding 0帶來的誤差影響慰技。
4)如果是中長文本椭盏,超過100個詞,采用Bi-LSTM框架 則需要加入attention機制吻商,來挑選關鍵的詞掏颊。
5)……