騰訊抗黑灰產——自監督發現行話黑詞識別一詞多義

  • 2019 年 11 月 22 日
  • 筆記

本文作者:lorenzwang ,騰訊 TEG 安全工程師

常見的中文 NLP 下游任務一般都是以分詞作為起點(以 transformer 為核心的演算法除外),對每個詞取 embedding,作為模型的輸入。不過在黑灰產領域,這種處理方法有一個問題:大量的黑話/黑詞對於下游任務非常有效,但卻不在通用的詞典中,導致分詞器無法準確切分出對應的詞。比如,今年 315 晚會曝光的「714 」,再比如「口子」。以及本人參加新人培訓時講師提的一些 00 後常用詞,「擴列」, 「暖說說」。

筆者所在的防水牆團隊整合了多源異質數據,在黑產人群識別、威脅滲透、黑產對抗等場景具備行業領先的能力。作為黑灰產能力建立的基礎,行話/黑詞的識別顯得至關重要。本文將介紹一些我們在新詞發現及一詞多義上的解決方案。

1 新詞發現

新詞發現方法較多,本文將介紹一種比較簡單且有效的新詞發現方案:自由度+凝固度。

我們首先來定義一個問題:

怎樣的字元可以組成一個中文語義下的「詞」

1.1 具有比較豐富的上文、下文

詞作為中文一個基本的語義單元,具備一個比較顯著的特徵:可以比較靈活的應用到不同場景中。比如說「機器學習」這個詞,上下文均可以搭配很多動詞和名詞,「學習人工智慧知識」, 「從事人工智慧行業」, 「選修人工智慧課程」,「基於人工智慧 xx」,「人工智慧賦予了 xx」, 「人工智慧識別了 xx」,「人工智慧實現了 xx」。但是對於「人工智」這個詞來說,上文依然可以搭配很多詞語,可是下文基本上只能搭配「能」了。

再比如筆者的家鄉:臨沂。「住在臨沂」,「建設大美臨沂」, 「臨沂煎餅」, 「臨沂機場」都存在豐富的上文、下文,但是對於「沂」(友情提示:yi, 二聲),下文可以接「煎餅」, 「機場」, 「人」,但是上文則大概率只有「臨」,以及其他幾個很少的字。

寫到這裡,各位讀者可以自己自己考慮下一些常用詞是否符合這一點。

上文和下文的豐富程度可以用資訊熵來度量:

熵是一種表示資訊量的指標,熵越高意味著不確定越高,越難以預測

讓我們用一個實際的例子來感受下。假設我們有一個硬幣,每次拋起來之後正面向上的概率為 x, x

range(0, 1, 0.1):

x取值與熵的關係

從圖中可以看出,當正面向上的概率 x=0 或 x=1 時,熵=0,因此此時隨便拋一次硬幣我們都可以準確的預測到正面是否朝上。熵的最大值發生在 x=0.5 處,x=0.5 時我們預測准下一次拋硬幣時正面向上的概率最小。

對應到詞的上文和下文,對於那些上文(下文)不豐富的詞來說,我們可以比較大概率的預測准詞的上文或下文,比如「珠穆朗」這個詞,它的下文大概率是「瑪」, 比如「沂」這個詞,它的上文大概率是「臨」,對於「珠穆朗」,我們稱它的右資訊熵比較小(資訊熵越小,則確定性越高),對於「沂」這個詞,我們稱它的左資訊熵比較小(資訊熵越小,確定性越高)。

詞的上文和下文越豐富,則其左資訊熵(右資訊熵)越大。一般來說,我們取左右資訊熵中的最小值(考慮下這是為什麼)。

1.2 詞的內部凝聚度要足夠高

上面提到說對於一個合格的詞語而言,需要具有豐富的上下文(不然就沒必要作為一個基本的不可劃分的語義單元了),但滿足了這一點就可以了嘛?讓我們來看一個例子。

小明在學校看了小紅的演唱會

其中,「在學校」幾乎百搭,「的演唱會」也是,不信來看:

xx/在學校/xx:他在學校經常搗亂、美術展在學校美術館舉辦xx/的演唱會/xx:周杰倫的演唱會棒極了、成功的演唱會太難了

但是「在學校」和「的演唱會」很明顯不是我們直覺上的詞,為什麼會出現這種情況?因為「在」和「的」在中文中出現的太頻繁了(「的太頻繁」是不是也符合擁有豐富的上文、下文這一點?)

很明顯,合格的詞不僅要在外部有豐富的上文、下文,其本身內部也要滿足一定的條件。上面我們講過,詞是一個基本的語義單元,意味著一般情況下不應該繼續細分了,這也就意味著詞內部要比較穩固或者內部凝固程度比較高。穩固意味著不可分,不可分如何衡量?

先說結論,我們用(點間)互資訊衡量詞內部的凝聚程度:

公式最右邊是

, 假如 x 和 y 完全獨立,

,上面公式=0。

假如有一篇介紹臨沂的文章,總共有 100 個字,其中,臨沂出現 1 次,臨出現一次,沂出現一次,那麼 p(臨,沂)=1/100, p(臨)=1/100,p(沂)=1/100,

我們可以看到「臨沂」這個詞內部還是比較穩固的,向心力比較強,離心力很小

1.3 總結

基於上述兩點,我們可以得出如下結論:詞之所以成詞,其外部需要有比較豐富的上文和下文,其內部要足夠穩固一般不可再分。

因此我們可以設計如下指標:

基於文本選取合適的 score,也可以分別取左熵,右熵,PMI 的閾值對詞進行篩選。

1.4 新詞發現流程

  • 生成候選詞

這一步我們將文本按字分割後拼接為二元組,三元組,…,k 元組(一般 k <= 5),如「新詞發現及一詞多義的解決方案」對應的二元組有:[「新詞」, 「詞發」, 「發現」, 「現及」, 「及一」, 「一詞」, 「詞多」, 「多義」, 「義的」, 「的解」, 「解決」, 「決方」, 「方案」]。

在這一步如果採用現有的分詞工具進行分詞會導致很多詞可能在這一步就被拆分開了,對應的詞後續無法被識別到。

  • 候選詞得分計算

針對上述的每一個詞,我們都計算對應的 score 或者

,然後基於特定的閾值進行篩選即可得到新詞。

  • 將新詞加入到分詞器的詞典中

將通過上述步驟得到的詞加入到分詞器的詞典中,如在 jieba 中採用如下方式:

jieba.add_word("德瑪西亞")

後續採用這個分詞器進行分詞時,相應的新詞

  • 計算得到每個詞的 embedding vector

可以採用諸如 word2vec, glove, bert 之類的任意演算法

  • 基於種子黑詞,計算新詞(或所有詞)與種子黑詞的相似度,篩選得到黑詞

比如種子詞選取毒品,最終發現「溜冰」這個原本看似人畜無害的詞與毒品相關的種子詞相似程度均很高,即可推測自己發現了一個該領域的新詞。

2 一詞多義

首先讓我們來看一下例子:

溜冰 對於那些天真無邪的同學來說,溜冰就是在冰上溜來溜去。 對於那些了解世事的同學來說,溜冰就是在冰上溜來溜去。

只不過,「此冰非彼冰,此溜也非彼溜」 。

和 714 、擴列、暖說說這種需要新詞發現才能識別的新詞不同(嚴格來說現在擴列已經成為通用詞了),溜冰是現有的詞,只是在特定的場景下意義發生了變化。如下面兩句:

周末和小夥伴一起去溜冰 周末和小夥伴在出租屋溜冰

nlp 任務的輸入一般是詞的 emb vector, 上一步完成新詞發現保證黑詞/行話能夠被正常發現後我們可以對分詞後的文本進行嵌入。

word2vec 生成的靜態詞嵌入無法解決一詞多義問題,BERT 等雖然可以解決一詞多義,但是對於單純的新詞發現任務/黑詞擴散等任務來說顯得有點多此一舉了。因此我們在這個 bert 已經大殺四方的時候選擇了嘗試 ELMo.

2.1 什麼是靜態詞向量

靜態詞向量的生成過程:訓練 language model,將 language model 中預測的 hidden state 作為 word 的表示。給定 N 個 tokens 的序列(

),前向 language model 就是基於前 k-1 個輸入序列去預測第 k 個位置的 token,訓練目標即:

像常見的 word2vec, glove 生成的都是靜態詞向量。但是比較反常識的是,word2vec,glove 這些對每個詞都會生成一個確定的 emb vector。

對於前面我們提到的「溜冰」,不同語境下很明顯其 vec 應該不同

2.2 ELMo 的解決方式

ELMo 不再得到詞的確定的 emb vec, 而是得到一個訓練好的 language model(以下用 LM 代指,切記不是 likelihood maximization)。這個 LM 會基於給定的上下文「動態的」生成每個詞的嵌入。

這裡再啰嗦幾句。

從結果上看,我們最終生成的 emb vec 肯定是常量而不是隨機量。

這裡的動態指的是每次輸入不同的 context,嵌入都會不同,從過程上看嵌入是動態的。ELMo 本身是個根據上下文對 emb 動態調整的思路。

所以,ELMo 採用了典型的兩階段過程,第一階段利用語言模型進行預訓練,第二階段是在做下游任務時,從預訓練語言模型中提取對應單詞的 emb 作為新特徵補充道下游任務中。

上文之所以寫這些是因為,我們剛開始具體在用 ELMo 的時候,忽略了第一和第二階段,以為可以直接把第一階段訓練得到的語言模型中的單詞的 emb 拿出來作為 ELMo 的產出,實際不是的。第一階段訓練完畢後,雖然每個單詞也有一個 emb,但是這個 emb 只是一個中間產物,直接拿來用效果會差到難以想像(痛徹心扉!!)

本文不贅述 ELMo 的理論,下面兩部分將分別講述 ELMo 的預訓練和實際應用。

2.3 ELMo 第一階段 -- ELMo 預訓練

先上源碼,步驟:

  1. 新詞發現及分詞並訓練 w2v 詞向量,得到詞向量 emb,語料 corpus 和詞表 vocab
  2. 修改程式碼
  3. 訓練模型,vocab_embedding.hdf5
  4. 得到 model weights

2.3.1 新詞發現及分詞部分

類比我們在本文第一部分的工作,先進行新詞發現,將發現的新詞加入到分詞的詞表,進行分詞操作。

1)詞向量部分

偽程式碼:

for item in new_words:        jieba.add_word(item)  stopwords = ...  df_mid = df.rdd.flatMap(lambda x: jieba.lcut(x)).toDF(['sentence'])    w2v = Word2Vec(vectorSize = , inputCol = 'sentence')  model = w2v.fit(df_mid)  word_df = model.getVectors()

上面操作可以得到詞的 w2v 詞向量。由於後續詞表需要按詞頻從高到低排列,並且詞表中的詞需要和詞向量中的嵌入向量一一對應,因此這裡得到嵌入詞向量之後,需要把詞向量文件按照詞頻從大到小進行排序。

詞向量文件開頭第一行分別是詞數和詞向量維度,形式如下:

圖中全部的分隔符都是空格,不是't'

2)語料。corpus 是分詞後的語料,每一行是一個 string, 用空格分隔:

3)詞表。得到詞向量部分對應的詞,詞需要按照在語料中的詞頻從高到低排列。鑒於我們在 1)中已經把詞向量按照詞頻排序了,這裡只需要把詞拿出來單純保存即可。

詞表開頭必須是<S>, </S>, <UNK>,且大小寫敏感,形式如下:

1)和 3)部分的程式碼如下:

with open('trans_data/word_vectors','r', encoding='utf-8') as f:      with open('trans_data/vectors.txt', 'w', encoding='utf-8') as fout:          fout.write(str(word_count) + ' ' + str(dim) + 'n')          with open('trans_data/vocab.txt', 'w', encoding='utf-8') as fvocab:              fvocab.write('<S>')              fvocab.write('n')              fvocab.write('</S>')              fvocab.write('n')              fvocab.write('<UNK>')              fvocab.write('n')              for line in f:                  x = line.split('t')                  tmp = x[1]                  tmp = tmp.strip('')                  tmp = tmp.lstrip('[')                  tmp = tmp.rstrip(']n')                  tmp = tmp.replace(',', ' ')                  vocab.append(x[0])                  item = x[0] + ' ' + tmp + 'n'                  fout.write(item)                  fvocab.write(x[0] + 'n')

2.3.2 修改程式碼

├── bilm                  # 模型文件目錄  │   ├── __init__.py  │   ├── data.py           # 數據準備入口  │   ├── elmo.py           # 加總elmo不同層得到輸出  │   ├── model.py          # 雙向語言模型結構文件  │   └── training.py       # 模型架構  ├── bin                   # 訓練文件目錄  │   ├── dump_weights.py  │   ├── restart.py  │   ├── run_test.py  │   └── train_elmo.py     # 訓練入口  ├── test  │   ├── test_data.py  │   ├── test_elmo.py  │   ├── test_model.py  │   └── test_training.py  └── usage_token.py        # 示例

最重要的幾個文件是 bilm/training.py, bin/train_elmo.py

1)修改訓練參數 bin/train_elmo.py

實際應用主要修改以下參數:

1.batch_size

2.epoch

3.n_gpus and cuda_visible_devices

4.n_train_tokens

5.projection_dim,決定了elmo輸出向量的維度

2)修改 bilm/training.py 中的 LanguageModel 類

將上面生成的詞的 w2v 向量通過initializer = tf.constant_initializer(tmp_embed)傳進去,隨後通過 embedding_lookup 查表對應到批次內的詞

3)保存 embedding 用於第二階段

4)輸出訓練 loss

初始程式碼中並未輸出每個 batch 對應的 loss,導致開始的時候無法判斷模型是否收斂,畢竟簡單的解決方案是周期性的在日誌中列印 train_perplexity

5)列印更多資訊

原始程式碼並未輸出詳細的訓練資訊,建議在一些關鍵步驟上列印相應資訊,後續調優等操作可以有的放矢。

2.3.3 訓練模型,得到 vocab_embedding.hdf5

nohup python3 -u bin/train_elmo.py --train_prefix='/data/home/xxxx/elmo_data/trans_data/corpus.txt' --vocab_file /data/home/xxxx/elmo_data/trans_data/vocab.txt --save_dir /data/home/xxxx/bilm-tf/output_dir > /data/home/xxxx/bilm-tf/output_dir/bilm_out.txt 2>&1 &

其中,

nohup: 退出 shell 不退出進程train_elmo.py: 主程式入口train_prefix: 語料路徑vocab_file: 詞表路徑save_dir: 訓練日誌、checkpoint、options.json 輸出路徑

輸出文件如下:

不過,訓練完之後我們還得到了一個最重要的輸出:vocab_embedding.hdfs(參見 2.3.2 中的第三部分)

2.3.4 得到 weights.hdf5

上面一步計算得到了 ckpt 文件,下面進一步得到 model weights

nohup python3 -u bin/dump_weights.py --save_dir /data1/home/xxxx/bilm-tf/output_dir --outfile /data1/home/xxxx/elmo_data/trans_out/weights.hdf5 > /data1/home/xxxx/elmo_data/trans_out/bilm_out_weights.txt 2>&1 &

save_dir: 上面保存 ckpt 的路徑 outfile: model weights 輸出路徑

2.3.5 總結

vocab_embedding 是 vocab 的一個初始嵌入,不是 ELMo 的最終輸出!不是 ELMo 的最終輸出!不是 ELMo 的最終輸出!

weights.hdf5 是 language model 的係數

有了:

  • vocab_embedding.hdf5
  • weights.hdf5
  • options.json 

就可以把 ELMo 用起來了!

BTW:訓練過程中,總共有 93246334 條語料(文本比較短,且對分詞後的文本進行了過濾,平均文本長度大概在 10 個詞),峰值 cpu 佔用 50g,4 張 Tesla K40m 跑 80 個 epoch 需要 10 個小時。

2.4 ELMo 第二階段 -- 得到語料的 ELMo embedding

2.4.1 訓練程式碼及過程

源碼中的 usage_token.py 是 ELMo 第二階段的示例,不過例子並不好,可以基於這個示例進行改寫。下面提供一個偽程式碼:

import tensorflow as tfimport osimport numpy as npfrom bilm import TokenBatcher, BidirectionalLanguageModel, weight_layers, dump_token_embeddings# 根據實際情況進行修改vocab_file = '/data/home/xxxx/elmo_data/trans_data/vocab4.txt'options_file = '/data/home/xxxx/bilm-tf/output_dir/options.json'weight_file = '/data/home/xxxx/elmo_data/trans_out/weights_8.hdf5'token_embedding_file = '/data/home/xxxx/elmo_data/trans_out/vocab_embedding_8.hdf5'tokenized_context = [['吸毒', '溜冰', '販毒', '吸毒', '販毒', '吸毒', '毒品', '吸毒'],                                                     ['定期', '組織', '吸毒', '活動', '販毒', '制毒', '毒品', '情況', '溜冰', '吸毒'],                     ['星期天', '中午', '組隊', '體育場', '文化宮', '溜冰', '熱愛', '輪滑', '溜友', '踴躍報名', '參加']]
# Create a TokenBatcher to map text to token ids.batcher = TokenBatcher(vocab_file)# Input placeholders to the biLM.context_token_ids = tf.placeholder('int32', shape=(None, None))# Build the biLM graph.bilm = BidirectionalLanguageModel(    options_file,    weight_file,    use_character_inputs=False,    embedding_weight_file=token_embedding_file)# Get ops to compute the LM embeddings.context_embeddings_op = bilm(context_token_ids)elmo_context_output = weight_layers('output', context_embeddings_op, l2_coef=0.0)with tf.Session() as sess:# It is necessary to initialize variables once before running inference.    sess.run(tf.global_variables_initializer())# Create batches of data.    context_ids = batcher.batch_sentences(tokenized_context)# Compute ELMo representations (here for the output).    elmo_context_output_ = sess.run(        elmo_context_output['weighted_op'],        feed_dict={context_token_ids: context_ids}    )    print('elmo_context_ouput_:')    print(elmo_context_output_.shape)    print(elmo_context_output_)# ------------------------elmo_context_output才是elmo真正的輸出------------------------------## sentences similarities    d1, d2, d3 = elmo_context_output_.shape# d1 = 3, d2 = 11, d3 = 128, d2=所有sentences中最大長度# 維度128 = projection_dim * 2(因為elmo會把前向和後向語言模型concat起來,所以最終生成的維度是128)    group_vector_output = np.array([]).reshape(0, 128)for i in range(d1):        tmp_vec_out = np.sum(elmo_context_output_[i, :, :], axis=0) # 把每個句子中所有token的emb加總起來        sentence_vector_output = np.vstack([sentence_vector_output, tmp_vec_out])        print(str(i)+"th sentence_vector_output: ")        print(sentence_vector_output)    print('output result')# 接下來計算三個句子間的similaritiesfor i in range(d1):        vec1 = sentence_vector_output[i, :]for j in range(i+1, d1):            vec2 = sentence_vector_output[j, :]            num = vec1.dot(vec2.T)            denom = np.linalg.norm(vec1) * np.linalg.norm(vec2)            cos = num / denom            print(str(i)+ ' ' + str(j) + ' ' + str(cos))# 接下來計算三個句子中「溜冰」這個單詞的相似度# elmo_context_output_[0, 1, :]對應第一個句子中的第2個token,# elmo_context_output_[1, 8, :]對應第二個句子中的第9個token,# elmo_context_output_[2, 5, :]對應第三個句子中的第6個token,# 正好分別對應著各自句子中溜冰的位置    print('0 1')    num = elmo_context_output_[0, 1, :].dot(elmo_context_output_[1, 8, :].T)    denom = np.linalg.norm(elmo_context_output_[0, 1, :]) * np.linalg.norm(elmo_context_output_[1, 8, :])print (num / denom)    print('1 2')    num = elmo_context_output_[1, 8, :].dot(elmo_context_output_[2, 5, :].T)    denom = np.linalg.norm(elmo_context_output_[1, 8, :]) * np.linalg.norm(elmo_context_output_[2, 5, :])    print(num / denom)    print('0 2')    num = elmo_context_output_[0, 1, :].dot(elmo_context_output_[2, 5, :].T)    denom = np.linalg.norm(elmo_context_output_[0, 1, :]) * np.linalg.norm(elmo_context_output_[2, 5, :])    print(num / denom)

輸出:

上圖表示的是三個句子兩兩之間的相似度:

上圖表示的是三個句子中溜冰之間的相似度,可以看出第一和第二個句子中的溜冰相似度最高,1 和 3, 2 和 3 中溜冰的相似度都會低一些,初步看符合我們的預期。

假如上述程式碼為 sen2vec.py,這一步只需要運行

python3 sen2vec.py

2.4.2 小結

實際應用中可以把候選文本都過一遍 elmo,將生成的 emb 存到 hadoop 表裡面,隨時調用效率會比較高。

另外可搭配上述新詞發現使用,效果更佳。

Reference:


「防水牆」是由騰訊安全團隊打造的一款覆蓋金融、廣告、電商、新零售等行業的安全防護產品,在金融領域打造了覆蓋反欺詐、反洗錢、反催收及風險情報預警的全流程產品矩陣;在廣告領域,提供流量反作弊、Kingsman、內容監控及 KOL 甄選等服務;新零售領域,覆蓋生產、流通、銷售等核心環節風控,為商超、鞋服、日化等 KA 提供全流程安全服務,深度解決羊毛黨問題。防水牆可提供諸如註冊保護、登陸保護、驗證碼、活動防刷等服務,目前為內外部客戶提供日均 500 億+次的安全防護,更多詳情可見:https://007.qq.com

Tags: