人工智慧NLP專案_QA機器人(7)

魔法 • 革發表於2020-12-02

問答機器人介紹

目標

  1. 知道問答機器人是什麼

  2. 知道問答機器人實現的邏輯

1. 問答機器人

在前面的課程中,我們已經對問答機器人介紹過,這裡的問答機器人是我們在分類之後,對特定問題進行回答的一種機器人。至於回答的問題的型別,取決於我們的語料。

當前我們需要實現的問答機器人是一個回答程式語言(比如python是什麼python難麼等)相關問題的機器人

 

2. 問答機器人的實現邏輯

主要實現邏輯:從現有的問答對中,選擇出和問題最相似的問題,並且獲取其相似度(一個數值),如果相似度大於閾值,則返回這個最相似的問題對應的答案

問答機器人的實現可以大致分為三步步驟:

  1. 對問題的處理

  2. 對答案進行的機器學習召回

  3. 對召回的結果進行排序

2.1 對問題的處理

對問題的處理過程中,我們可以考慮以下問題:

  1. 對問題進行基礎的清洗,去除特殊符號等

  2. 問題主語的識別,判斷問題中是否包含特定的主語,比如python等,提取出來之後,方便後續對問題進行過濾。

    • 可以看出,不僅需要對使用者輸入的問題進行處理,獲取主語,還需要對現有問答對進行處理

  3. 獲取問題的詞向量,可以考慮使用詞頻,tdidf等值,方便召回的時候使用

2.2 問題的召回

召回:可以理解為是一個海選的操作,就是從現有的問答對中選擇可能相似的前K個問題。

為什麼要進行召回?

主要目的是為了後續進行排序的時候,減少需要計算的資料量,比如有10萬個問答對,直接通過深度學習肯定是可以獲取所有的相似度,但是速度慢。

所以考慮使用機器學習的方法進行一次海選

那麼,如何實現召回呢?

前面我們介紹,召回就是選擇前K個最相似的問題,所以召回的實現就是想辦法通過機器學習的手段計算器相似度。

可以思考的方法:

  1. 使用詞袋模型,獲取詞頻矩陣,計算相似度

  2. 使用tfidf,獲取tdidf的矩陣,計算相似度

上述的方法理論上都可行,知識當候選計算的詞語數量太多的時候,需要挨個計算相似度,非常耗時。

所以可以考慮以下兩點:

  1. 通過前面獲取的主語,對問題進行過濾

  2. 使用聚類的方法,對資料先聚類,再計算某幾個類別中的相似度,而不用去計算全部。

但是還有一個問題,供大家慢慢思考:

不管是詞頻,還是tdidf,獲取的結果肯定是沒有考慮文字順序的,效果不一定是最好的,那麼此時,應該如何讓最後召回的效果更好呢?

2.3 問題的排序

排序過程,使用了召回的結果作為輸入,同時輸出的是最相似的那一個。

整個過程使用深度學習實現。深度學習雖然訓練的速度慢,但是整體效果肯定比機器學習好(機器學習受限於特徵工程,資料量等因素,沒有辦法深入的學會不同問題之間的內在相似度),所以通過自建的模型,獲取最後的相似度。

使用深度學習的模型這樣一個黑匣子,在訓練資料足夠多的時候,能夠學習到使用者的各種不同輸入的問題,當我們把目標值(相似的問題)給定的情況下,讓模型自己去找到這些訓練資料目標值和特徵值之間相似的表示方法。

那麼此時,有以下兩個問題:

  1. 使用什麼資料,來訓練模型,最後返回模型的相似度

    訓練的資料的來源:可以考慮根據現有的問答對去手動構造,但是構造的資料不一定能夠覆蓋後續使用者提問的全部問題。所以可以考慮通過程式去採集網站上相似的問題,比如百度知道的搜尋結果。

  2. 模型該如何構建

    模型可以有兩個輸入,輸出為一個數值,兩個輸入的處理方法肯定是一樣的。這種網路結構我們經常把它稱作孿生神經網路。

    很明顯,我們隊輸入的資料需要進行編碼的操作,比如word embedding + LSTM/GRU/BIGRU等

    兩個編碼之後的結果,我們可以進行組合,然後通過一個多層的神經網路,輸出一個數字,把這個數值定義為我們的相似度。

    當然我們的深層的神經網路在最開始的時候也並不是計算的相似度,但是我們的訓練資料的目標值是相似度,在N多次的訓練之後,確定了輸入和輸出的表示方法之後,那麼最後的模型輸出就是相似度了。

前面我們介紹了問答機器人的實現的大致思路,那麼接下來,我們就來一步步的實現它

===============================

問答機器人的召回

目標

  1. 知道召回的目的

  2. 能夠說出召回的流程

  3. 能夠優化基礎的召回邏輯

1. 召回的流程

流程如下:

  1. 準備資料,問答對的資料等

  2. 問題轉化為向量

  3. 計算相似度

2. 對現有問答對的準備

這裡說的問答對,是帶有標準答案的問題,後續命中問答對中的問題後,會返回該問題對應的答案

為了後續使用方便,我們可以把現有問答對的處理成如下的格式,可以考慮存入資料庫或者本地檔案:

{
    "問題1":{
        "主體":["主體1","主體3","主體3"..],
        "問題1分詞後的句子":["word1","word2","word3"...],
        "答案":"答案"
    },
    "問題2":{
        ...
    }
}

程式碼如下:

# lib/get_qa_dcit.py
def get_qa_dict():
    chuanzhi_q_path = "./問答對/Q.txt"
    chuanzhi_a_path = "./問答對/A.txt"
    QA_dict = {}
    for q,a in zip(open(chuanzhi_q_path).readlines(),open(chuanzhi_a_path).readlines()):
        QA_dict[q.strip()] = {}
        QA_dict[q.strip()]["ans"] = a.strip()
        QA_dict[q.strip()]["entity"] = sentence_entity(q.strip())[-1]

    #準備短問答的問題
    python_duan_path = "./data/Python短問答-11月彙總.xlsx"

    ret = pd.read_excel(python_duan_path)
    column_list = ret.columns
    assert '問題' in column_list and "答案" in column_list, "excel 中必須包含問題和答案"
    for q, a in zip(ret["問題"], ret["答案"]):
        q = re.sub("\s+", " ", q)
        QA_dict[q.strip()] = {}
        QA_dict[q.strip()]["ans"] = a
        cuted,entiry = sentence_entity(q.strip())[-1]
        QA_dict[q.strip()]["entity"] = entiry
        QA_dict[q.strip()]["q_cuted"] = cuted

    return QA_dict

QA_dict = get_qa_dict()

3. 把問題轉化為向量

把問答對中的問題,和使用者輸出的問題,轉化為向量,為後續計算相似度做準備。

這裡,我們使用tfidf對問答對中的問題進行處理,轉化為向量矩陣。

TODO,使用單字,使用n-garm,使用BM25,使用word2vec等,讓其結果更加準確

from sklearn.feature_extraction.text import TfidfVectorizer
from lib import QA_dict

def build_q_vectors():
    """對問題建立索引"""
    lines_cuted= [q["q_cuted"] for q in QA_dict]
    tfidf_vectorizer = TfidfVectorizer()
    features_vec = tfidf_vectorizer.fit_transform(lines_cuted)
    #返回tfidf_vectorizer,後續還需要對使用者輸入的問題進行同樣的處理
	return tfidf_vectorizer,features_vec,lines_cuted

4. 計算相似度

思路很簡單。對使用者輸入的問題使用tfidf_vectorizer進行處理,然後和features_vec中的每一個結果進行計算,獲取相似度。

但是由於耗時可能會很久,所以考慮使用其他方法來實現

4.1 pysparnn的介紹

官方地址:https://github.com/facebookresearch/pysparnn

pysparnn是一個對sparse資料進行相似鄰近搜尋的python庫,這個庫是用來實現 高維空間中尋找最相似的資料的。

4.2 pysparnn的使用方法

pysparnn的使用非常簡單,僅僅需要以下步驟,就能夠完成從高維空間中尋找相似資料的結果

  1. 準備源資料和待搜尋資料

  2. 對源資料進行向量化,把向量結果和源資料構造搜尋的索引

  3. 對待搜尋的資料向量化,傳入索引,獲取結果

import pysparnn.cluster_index as ci

from sklearn.feature_extraction.text import TfidfVectorizer

#1. 原始資料
data = [
    'hello world',
    'oh hello there',
    'Play it',
    'Play it again Sam',
]  

#2. 原始資料向量化

tv = TfidfVectorizer()
tv.fit(data)

features_vec = tv.transform(data)

# 原始資料構造索引
cp = ci.MultiClusterIndex(features_vec, data)

# 待搜尋的資料向量化
search_data = [
    'oh there',
    'Play it again Frank'
]

search_features_vec = tv.transform(search_data)

#3. 索引中傳入帶搜尋資料,返回結果
cp.search(search_features_vec, k=1, k_clusters=2, return_distance=False)
>> [['oh hello there'], ['Play it again Sam']]

使用注意點:

  1. 構造索引是需要傳入向量和原資料,最終的結果會返回源資料

  2. 傳入待搜尋的資料時,需要傳入一下幾個引數:

    1. search_features_vec:搜尋的句子的向量

    2. k:最大的幾個結果,k=1,返回最大的一個

    3. k_clusters:對資料分為多少類進行搜尋

    4. return_distance:是否返回距離

4.3 使用pysparnn完成召回的過程

#構造索引
cp = ci.MultiClusterIndex(features_vec, lines_cuted)

#對使用者輸入的句子進行向量化
search_vec = tfidf_vec.transform(ret)
#搜尋獲取結果,返回最大的8個資料,之後根據`main_entiry`進行過濾結果
cp_search_list = cp.search(search_vec, k=8, k_clusters=10, return_distance=True)

exist_same_entiry = False
search_lsit = []
for _temp_call_line in cp_search_list[0]:
    cur_entity = QA_dict[_temp_call_line[1]]["main_entity"]
    if len(set(main_entity) & set(cur_entity))>0:  #命名體的集合存在交集的時候返回
        exist_same_entiry  = True
        search_lsit.append(_temp_call_line[1])

if exist_same_entiry: #存在相同的主體的時候
    return search_lsit
else:
    # print(cp_search_list)
    return [i[1] for i in cp_search_list[0]]

在這個過程中,需要注意,提前把cp,tfidf_vec等內容提前準備好,而不應該在每次接收到使用者的問題之後重新生成一遍,否則效率會很低

4.4 pysparnn的原理介紹

參考地址:https://nlp.stanford.edu/IR-book/html/htmledition/cluster-pruning-1.html

前面我們使用的pysparnn使用的是一種cluster pruning(簇修剪)的技術,即,開始的時候對資料進行聚類,後續再有限個類別中進行資料的搜尋,根據計算的餘弦相似度返回結果。

資料預處理過程如下:

  1. 隨機選擇個樣本作為leader

  2. 選擇非leader的資料(follower),使用餘弦相似度計算找到最近的leader

當獲取到一個問題q的時候,查詢過程:

  1. 計算每個leader和q的相似度,找到最相似的leader

  2. 然後計算問題q和leader所在簇的相似度,找到最相似的k個,作為最終的返回結果

在上述的過程中,可以設定兩個大於0的數字b1和b2

  • b1表示在資料預處理階段,每個follower選擇b1個最相似的leader,而不是選擇單獨一個lader,這樣不同的簇是有資料交叉的;

  • b2表示在查詢階段,找到最相似的b2個leader,然後再計算不同的leader中下的topk的結果

前面的描述就是b1=b2=1的情況,通過增加b1和b2的值,我們能夠有更大的機會找到更好的結果,但是這樣會需要更加大量的計算。

在pysparnn中例項化索引的過程中

即:ci.MultiClusterIndex(features, records_data, num_indexes)中,num_indexes能夠設定b1的值,預設為2。

在搜尋的過程中,cp.search(search_vec, k=8, k_clusters=10, return_distance=True,num_indexes)num_Indexes可以設定b2的值,預設等於b1的值。

=====================================

召回過程優化

目標

  1. 知道優化的方法和思路

  2. 知道BM25方法的原理和實現

  3. 能夠使用word2vector完成優化過程

1. 優化思路

前面的學習,我們能夠返回相似的召回結果,但是,如何讓這些結果更加準確呢?

我們可以從下面的角度出發:

  1. tfidf使用的是詞頻和整個文件的詞語,如果使用者問題的某個詞語沒有出現過,那麼此時,計算出來的相似度可能就不準確。該問題的解決思路:

    • 對使用者輸入的問題進行文字的對齊,比如,使用訓練好的word2vector,往句子中填充非主語的其他詞語的相似詞語。例如python 好學 麼 -->填充後是 :python 好學 麼 簡單 難 嘛,這裡假設word2vector同學會了好學,簡單,難他們之間是相似的

    • 使用word2vector對齊的好處除了應對未出現的詞語,還能夠提高主語的重要程度,讓主語位置的tfidf的值更大,從而讓相似度更加準確

  2. tfidf是一個詞袋模型,沒有考慮詞和詞之間的順序

    • 使用n-garm和詞一起作為特徵,轉化為特徵向量

  3. 不去使用tfidf處理句子得到向量。

    • 使用BM25演算法

    • 或者 使用fasttext、word2vector,把句子轉化為向

2. 通過BM25演算法代替TFIDF

2.1 BM25演算法原理

BM25(BM=best matching)是TDIDF的優化版本,首先我們來看看TFIDF是怎麼計算的

在一個句子中,某個詞重要程度應該是隨著詞語的數量逐漸衰減的,所以中間項對詞頻進行了懲罰,隨著次數的增加,影響程度的增加會越來越小。通過設定k值,能夠保證其最大值為k+1,k往往取值1.2

其變化如下圖(無論k為多少,中間項的變化程度會隨著次數的增加,越來越小):

2.2 BM25演算法實現

通過前面的學習,我們知道其實BM25和Tfidf的區別不大,所以我們可以在之前sciket-learn的TfidfVectorizer基礎上進行修改,獲取我們的BM25的計算結果,主要也是修改其中的fit方法和transform方法

在sklearn的TfidfVectorizer中,首先接受引數,其次會呼叫TfidfTransformer來完成其他方法的呼叫

  1. 繼承TfidfVectorizer完成 引數的接受

  from sklearn.feature_extraction.text import TfidfVectorizer,TfidfTransformer,_document_frequency
  from sklearn.base import BaseEstimator,TransformerMixin
  from sklearn.preprocessing import normalize
  from sklearn.utils.validation import check_is_fitted
  import numpy as np
  import scipy.sparse as sp
  
  class Bm25Vectorizer(CountVectorizer):
      def __init__(self,k=1.2,b=0.75, norm="l2", use_idf=True, smooth_idf=True,sublinear_tf=False,*args,**kwargs):
          super(Bm25Vectorizer,self).__init__(*args,**kwargs)
          self._tfidf = Bm25Transformer(k=k,b=b,norm=norm, use_idf=use_idf,
                                         smooth_idf=smooth_idf,
                                         sublinear_tf=sublinear_tf)
  
      @property
      def k(self):
          return self._tfidf.k
  
      @k.setter
      def k(self, value):
          self._tfidf.k = value
  
      @property
      def b(self):
          return self._tfidf.b
  
      @b.setter
      def b(self, value):
          self._tfidf.b = value
  
      def fit(self, raw_documents, y=None):
          """Learn vocabulary and idf from training set.
          """
          X = super(Bm25Vectorizer, self).fit_transform(raw_documents)
          self._tfidf.fit(X)
          return self
  
      def fit_transform(self, raw_documents, y=None):
          """Learn vocabulary and idf, return term-document matrix.
          """
          X = super(Bm25Vectorizer, self).fit_transform(raw_documents)
          self._tfidf.fit(X)
          return self._tfidf.transform(X, copy=False)
  
      def transform(self, raw_documents, copy=True):
          """Transform documents to document-term matrix.
          """
          check_is_fitted(self, '_tfidf', 'The tfidf vector is not fitted')
  
          X = super(Bm25Vectorizer, self).transform(raw_documents)
          return self._tfidf.transform(X, copy=False)

完成自己的Bm25transformer,只需要再原來基礎的程式碼上進心修改部分即可。sklearn中的轉換器類的實現要求,不能直接繼承已有的轉換器類

  class Bm25Transformer(BaseEstimator, TransformerMixin):
  
      def __init__(self,k=1.2,b=0.75, norm='l2', use_idf=True, smooth_idf=True,
                   sublinear_tf=False):
          self.k = k
          self.b = b
          ##################以下是TFIDFtransform程式碼##########################
          self.norm = norm
          self.use_idf = use_idf
          self.smooth_idf = smooth_idf
          self.sublinear_tf = sublinear_tf
  
      def fit(self, X, y=None):
          """Learn the idf vector (global term weights)
  
          Parameters
          ----------
          X : sparse matrix, [n_samples, n_features]
              a matrix of term/token counts
          """
          _X = X.toarray()
          self.avdl = _X.sum()/_X.shape[0] #句子的平均長度
          # print("原來的fit的資料:\n",X)
  
          #計算每個詞語的tf的值
          self.tf = _X.sum(0)/_X.sum()  #[M] #M表示總詞語的數量
          self.tf = self.tf.reshape([1,self.tf.shape[0]]) #[1,M]
          # print("tf\n",self.tf)
          ##################以下是TFIDFtransform程式碼##########################
          if not sp.issparse(X):
              X = sp.csc_matrix(X)
          if self.use_idf:
              n_samples, n_features = X.shape
              df = _document_frequency(X)
  
              # perform idf smoothing if required
              df += int(self.smooth_idf)
              n_samples += int(self.smooth_idf)
  
              # log+1 instead of log makes sure terms with zero idf don't get
              # suppressed entirely.
              idf = np.log(float(n_samples) / df) + 1.0
              self._idf_diag = sp.spdiags(idf, diags=0, m=n_features,
                                          n=n_features, format='csr')
  
          return self
  
      def transform(self, X, copy=True):
          """Transform a count matrix to a tf or tf-idf representation
  
          Parameters
          ----------
          X : sparse matrix, [n_samples, n_features]
              a matrix of term/token counts
  
          copy : boolean, default True
              Whether to copy X and operate on the copy or perform in-place
              operations.
  
          Returns
          -------
          vectors : sparse matrix, [n_samples, n_features]
          """
   		########### 計算中間項  ###############
          cur_tf = np.multiply(self.tf, X.toarray()) #[N,M] #N表示資料的條數,M表示總詞語的數量
          norm_lenght = 1 - self.b + self.b*(X.toarray().sum(-1)/self.avdl) #[N] #N表示資料的條數
          norm_lenght = norm_lenght.reshape([norm_lenght.shape[0],1]) #[N,1]
          middle_part = (self.k+1)*cur_tf /(cur_tf +self.k*norm_lenght)
          ############# 結算結束  ################
  
          if hasattr(X, 'dtype') and np.issubdtype(X.dtype, np.floating):
              # preserve float family dtype
              X = sp.csr_matrix(X, copy=copy)
          else:
              # convert counts or binary occurrences to floats
              X = sp.csr_matrix(X, dtype=np.float64, copy=copy)
  
          n_samples, n_features = X.shape
  
          if self.sublinear_tf:
              np.log(X.data, X.data)
              X.data += 1
          if self.use_idf:
              check_is_fitted(self, '_idf_diag', 'idf vector is not fitted')
  
              expected_n_features = self._idf_diag.shape[0]
              if n_features != expected_n_features:
                  raise ValueError("Input has n_features=%d while the model"
                                   " has been trained with n_features=%d" % (
                                       n_features, expected_n_features))
              # *= doesn't work
              X = X * self._idf_diag
  		
          ############# 中間項和結果相乘  ############
          X = X.toarray()*middle_part
          if not sp.issparse(X):
              X = sp.csr_matrix(X, dtype=np.float64)
          ############# #########
          
          if self.norm:
              X = normalize(X, norm=self.norm, copy=False)
  
          return X
  
      @property
      def idf_(self):
          ##################以下是TFIDFtransform程式碼##########################
          # if _idf_diag is not set, this will raise an attribute error,
          # which means hasattr(self, "idf_") is False
          return np.ravel(self._idf_diag.sum(axis=0))
  1. 完整程式碼參考:https://github.com/SpringMagnolia/Bm25Vectorzier/blob/master/BM25Vectorizer.py

  2. 測試簡單使用,觀察和tdidf的區別:

from BM25Vectorizer import Bm25Vectorizer
from sklearn.feature_extraction.text import TfidfVectorizer


if __name__ == '__main__':
    # format_weibo(word=False)
    # format_xiaohuangji_corpus(word=True)
    bm_vec = Bm25Vectorizer()
    tf_vec = TfidfVectorizer()
    # 1. 原始資料
    data = [
        'hello world',
        'oh hello there',
        'Play it',
        'Play it again Sam,24343,123',
    ]

    # 2. 原始資料向量化
    bm_vec.fit(data)
    tf_vec.fit(data)
    features_vec_bm = bm_vec.transform(data)
    features_vec_tf = tf_vec.transform(data)
    print("Bm25 result:",features_vec_bm.toarray())
    print("*"*100)
    print("Tfidf result:",features_vec_tf.toarray())

輸出如下:

   Bm25 result: [[0.         0.         0.         0.47878333 0.         0.
     0.         0.         0.         0.8779331 ]
    [0.         0.         0.         0.35073401 0.         0.66218791
     0.         0.         0.66218791 0.        ]
    [0.         0.         0.         0.         0.70710678 0.
     0.70710678 0.         0.         0.        ]
    [0.47038081 0.47038081 0.47038081 0.         0.23975776 0.
     0.23975776 0.47038081 0.         0.        ]]
   **********************************************************************************
   Tfidf result: [[0.         0.         0.         0.6191303  0.         0.
     0.         0.         0.         0.78528828]
    [0.         0.         0.         0.48693426 0.         0.61761437
     0.         0.         0.61761437 0.        ]
    [0.         0.         0.         0.         0.70710678 0.
     0.70710678 0.         0.         0.        ]
    [0.43671931 0.43671931 0.43671931 0.         0.34431452 0.
     0.34431452 0.43671931 0.         0.        ]]

2.3 修改之前的召回程式碼

修改之前召回的程式碼只需要把呼叫tfidfvectorizer改成呼叫Bm25vectorizer

3. 使用Fasttext實現獲取句子向量

3.1 基礎方法介紹

這裡我們可以使用fasttext,word2vector等方式實現獲取詞向量,然後對一個句子中的所有詞語的詞向量進行平均,獲取整個句子的向量表示,即sentence Vector,該實現方法在fasttext和Word2vector中均有實現,而且通過引數的控制,實現N-garm的效果

假設我們有文字a.txt如下:

我 很 喜歡 她 
今天 天氣 不錯
我 愛 深度學習

那麼我們可以實現獲取句子向量的方法如下

from fastText import FastText
#訓練模型,設定n-garm=2
model = FastText.train_unsupervised(input="./a.txt",minCount=1,wordNgrams=2)
#獲取句子向量,是對詞向量的平均
model.get_sentence_vector("我 是 誰")

3.2 訓練模型和封裝程式碼

這裡我們使用之前採集的相似文字資料作為訓練樣本

步驟如下:

  1. 進行分詞之後寫入檔案中

  2. 進行模型的訓練

  3. 使用模型獲取句子向量,並且封裝程式碼

  4. 將之前的BM25的程式碼替換為該程式碼

3.2.1 分詞寫入檔案

這裡我們使用單個字作為特徵,只需要注意,英文使用單個詞作為特徵

"""
使用單個字作為特徵,進行fasttext訓練,最後封裝程式碼獲取召回結果
"""
import string


def word_split(line):
    #對中文按照字進行處理,對英文不分為字母
    #即 I愛python --> i 愛 python
    letters = string.ascii_lowercase+"+"+"/"  #c++,ui/ue
    result = []
    temp = ""
    for word in line:
        if word.lower() in letters:
            temp+=word.lower()
        else:
            if temp !="":
                result.append(temp)
                temp = ""
            result.append(word)
    if temp!="":
        result.append(temp)
    return result

def process_data():
    path1 = r"corpus\final_data\merged_q.txt"
    path2 = r"corpus\final_data\merged_sim_q.txt"
    save_path =  r"corpus\recall_fasttext_data\data.txt"

    filter = set()
    with open(path1) as f,open(save_path,"a") as save_f:
        for line in f:
            line = line.strip()
            if line not in filter:
                filter.add(line)
                _temp = " ".join(word_split(line))
                save_f.write(_temp+"\n")

    with open(path2) as f,open(save_path,"a") as save_f:
        for line in f:
            line = line.strip()
            if line not in filter:
                filter.add(line)
                _temp = " ".join(word_split(line))
                save_f.write(_temp+"\n")

3.2.2 訓練模型

  1. 訓練fasttext的model,用來生成詞向量

  def train_model(fasttext_model_path):
   logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
   save_path =  r"corpus\recall_fasttext_data\data.txt"
  
   model = FastText.train_unsupervised(save_path,epoch=20,minCount=3,wordNgrams=2)
   model.save_model(fasttext_model_path)

對現有的QA問答對,生成向量,傳入pysparnn中構建索引

def get_base_text_vectors(cp_dump_path,model):
    #儲存到本地pkl檔案,防止每次都生成一次
    if os.path.exists(cp_dump_path):
        cp = pickle.load(open(cp_dump_path,"rb"))
    else:
        print(QA_dict)
        q_lines = [q for q in QA_dict]
        q_cuted_list = [" ".join(word_split(i)) for i in q_lines]
        lines_vectors = []
        for q_cuted in q_cuted_list:
            lines_vectors.append(model.get_sentence_vector(q_cuted))
        cp = ci.MultiClusterIndex(lines_vectors,q_lines)
        pickle.dump(cp,open(cp_dump_path,"wb"))
    return cp

傳入使用者的問題,進行分詞和句子向量的獲取,獲取搜尋的結果

def get_search_vectors(cp,model,search_line):
    line_cuted = " ".join(word_split(search_line))
    line_vec = model.get_sentence_vector(line_cuted)
    #這裡的line_vec中可以有多個句子的向量表示,能夠返回每個句子的搜尋結果
    cp_search_list = cp.search(line_vec,k=10,k_clusters=10,return_distance=True)
    #TODO 對搜尋的結果進行關鍵字的過濾
    return cp_search_list

測試模型的效果

  from fastext_vectors import get_search_vectors,train_model,get_base_text_vectors
  import fastText
  
  if __name__ == '__main__':
      fasttext_model_path = "corpus/build_questions/fasttext_recall.model"
      cp_dump_path = "corpus/build_questions/cp_recall.pkl"
      
      # train_model(fasttext_model_path)
      
      model = fastText.load_model(fasttext_model_path)
  
      cp = get_base_text_vectors(cp_dump_path,model)
  
      ret = get_search_vectors(cp,model,"女孩學python容易麼?")
      print(ret)

輸出如下:

  [[('0.0890376', '學習Python需要什麼基礎,學起來更容易?'), 
    ('0.090688944', '學習PHP的女生多嗎?女生可以學嗎?'), 
    ('0.092773676', 'Python適合什麼人學習?'), 
    ('0.09416294', 'Python語言適合什麼樣的人學?'), 
    ('0.102790296', 'python語言容易學習嗎?'), 
    ('0.1050359', '學習測試的女生多嗎?女生可以學嗎?'), 
    ('0.10546541', 'Python好學嗎?'), 
    ('0.11058545', '學習Python怎樣?'), 
    ('0.11080605', '怎樣學好Python?'), 
    ('0.11124289', '學生怎麼上課的?')]]

3.2.3 基礎封裝

#lib/SentenceVectorizer
"""
使用fasttext 實現sentence to vector
"""
import fastText
from fastText import FastText
import config
from lib import cut
import logging
import os

class SentenceVectorizer:
    def __init__(self):
        if os.path.exists(config.recall_fasttext_model_path):
            self.model = fastText.load_model(config.recall_fasttext_model_path)
        else:
            # self.process_data()
            self.model = self.build_model()

        self.fited = False


    def fit_transform(self,sentences):
        """處理全部問題資料"""
        lines_vectors = self.fit(sentences)
        return lines_vectors

    def fit(self,lines):
        lines_vectors = []
        for q_cuted in lines:
            lines_vectors.append(self.model.get_sentence_vector(q_cuted))
        self.fited = True
        return lines_vectors

    def transform(self,sentence):
        """處理使用者輸入的資料"""
        assert self.fited = True
        line_vec = self.model.get_sentence_vector(" ".join(sentence))
        return line_vec


    def build_model(self):
        logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
        model = FastText.train_unsupervised(config.recall_fasttext_data_path, epoch=20, minCount=3, wordNgrams=2)
        model.save_model(config.recall_fasttext_model_path)
        return model

    def process_data(self):
        path1 = r"corpus\final_data\merged_q.txt"
    	path2 = r"corpus\final_data\merged_sim_q.txt"
    	save_path =  r"corpus\recall_fasttext_data\data.txt"

        filter = set()
        with open(path1) as f, open(save_path, "a") as save_f:
            for line in f:
                line = line.strip()
                if line not in filter:
                    filter.add(line)
                    _temp = " ".join(cut(line,by_word=True))
                    save_f.write(_temp + "\n")

        with open(path2) as f, open(save_path, "a") as save_f:
            for line in f:
                line = line.strip()
                if line not in filter:
                    filter.add(line)
                    _temp = " ".join(cut(line,by_word=True))
                    save_f.write(_temp + "\n")

問答機器人排序模型

目標

  1. 知道模型中排序中的概念和目的

  2. 知道模型中排序的實現方法

1. 排序模型的介紹

前面的課程中為了完成一個問答機器人,我們先進行了召回,相當於是通過海選的方法找到呢大致相似的問題。

通過現在的排序模型,我們需要精選出最相似的哪一個問題,返回對應的答案

 

2. 排序模型的實現思路

我們需要實現的排序模型是兩個輸入,即兩個問題,輸出的是一個相似度。所以和之前的深度學習模型一樣,我們需要實現的步驟如下:

  1. 準備資料

  2. 構建模型

  3. 模型評估

  4. 對外提供介面返回結果

2.1 準備資料

這裡的資料,我們使用之前採集的百度問答的相似問題和手動構造的資料。那麼,我們需要把他格式化為最終模型需要的格式,即兩個輸入和輸出的相似度。

2.1.1 兩個輸入

這裡的輸入,我們可以使用單個字作為特徵,也可以使用一個分詞之後的詞語作為特徵。所以在實現準備輸入資料方法的過程中,可以提前準備。

2.1.2 相似度準備

這裡我們使用每個問題搜尋結果的前兩頁認為他們是相似的,相似度為1,最後兩頁的結果是不相似的,相似度為0。

 

2.2 構建模型

介紹模型的構建之前,我們先介紹下孿生神經網路(Siamese Network)和其名字的由來。

Siamese和Chinese有點像。Siamese是古時候泰國的稱呼,中文譯作暹羅。Siamese在英語中是“孿生”、“連體”的意思。為什麼孿生和泰國有關係呢?

十九世紀泰國出生了一對連體嬰兒,當時的醫學技術無法使兩人分離出來,於是兩人頑強地生活了一生,1829年被英國商人發現,進入馬戲團,在全世界各地表演,1839年他們訪問美國北卡羅萊那州後來成為馬戲團的臺柱,最後成為美國公民。1843年4月13日跟英國一對姐妹結婚,恩生了10個小孩,昌生了12個,姐妹吵架時,兄弟就要輪流到每個老婆家住三天。1874年恩因肺病去世,另一位不久也去世,兩人均於63歲離開人間。兩人的肝至今仍儲存在費城的馬特博物館內。從此之後“暹羅雙胞胎”(Siamese twins)就成了連體人的代名詞,也因為這對雙胞胎讓全世界都重視到這項特殊疾病。

所以孿生神經網路就是有兩個共享權值的網路的組成,或者只用實現一個,另一個直接呼叫,有兩個輸入,一個輸出。1993年就已經被用來進行支票簽名的驗證。

embedding,GRU,biGRU等),Network3部分是一個深層的神經網路,包含(batchnorm、dropout、relu、Linear等層)

2.3 模型的評估

編寫預測和評估的程式碼,預測的過程只需要修改獲得結果,不需要上圖中的損失計算的過程

3. 程式碼實現

3.1 資料準備

3.1.1 對文字進行分詞分開儲存

這裡的分詞可以對之前的分詞方法進行修改

def cut_sentence_by_word(sentence):
    # 對中文按照字進行處理,對英文不分為字母
    letters = string.ascii_lowercase + "+" + "/"  # c++,ui/ue
    result = []
    temp = ""
    for word in line:
        if word.lower() in letters:
            temp += word.lower()
        else:
            if temp != "":
                result.append(temp)
                temp = ""
            result.append(word)
    if temp != "":
        result.append(temp)
    return result

def jieba_cut(sentence,by_word=False,with_sg=False,use_stopwords=False):
    if by_word:
        return cut_sentence_by_word(sentence)
    ret = psg.lcut(sentence)
    if use_stopwords:
        ret = [(i.word, i.flag) for i in ret if i.word not in stopwords_list]
    if not with_sg:
        ret = [i[0] for i in ret]
    return ret

3.1.2 準備word Sequence程式碼

該處的程式碼和seq2seq中的程式碼相同,直接使用

3.1.3 準備DatasetDataLoader

和seq2seq中的程式碼大致相同

3.2 模型的搭建

前面做好了準備工作之後,就需要開始進行模型的搭建。

雖然我們知道了整個結構的大致情況,但是我們還是不知道其中具體的細節。

2016年AAAI會議上,有一篇Siamese Recurrent Architectures for Learning Sentence Similarity的論文(地址:https://www.aaai.org/ocs/index.php/AAAI/AAAI16/paper/download/12195/12023)。整個結構如下圖:

可以看到word 經過embedding之後進行LSTM的處理,然後經過exp來確定相似度,可以看到整個模型是非常簡單的,之後很多人在這個結構上增加了更多的層,比如加入attention、dropout、pooling等層。

那麼這個時候,請思考下面幾個問題:

  1. attention在這個網路結構中該如何實現

    • 之前我們的attention是用在decoder中,讓decoder的hidden和encoder的output進行運算,得到attention的weight,再和decoder的output進行計算,作為下一次decoder的輸入

    • 那麼在當前我們可以把句子A的output理解為句子B的encoder的output,那麼我們就可以進行attention的計算了

      和這個非常相似的有一個attention的變種,叫做self attention。前面所講的Attention是基於source端和target端的隱變數(hidden state)計算Attention的,得到的結果是源端的每個詞與目標端每個詞之間的依賴關係。Self Attention不同,它分別在source端和target端進行,僅與source input或者target input自身相關的Self Attention,捕捉source端或target端自身的詞與詞之間的依賴關係。

  2. dropout用在什麼地方

    • dropout可以用在很多地方,比如embedding之後

    • BiGRU結構中

    • 或者是相似度計算之前

  3. pooling是什麼如何使用

    • pooling叫做池化,是一種降取樣的技術,用來減少特徵(feature)的數量。常用的方法有max pooling 或者是average pooling

3.2.1 編碼部分

    def forward(self, *input):
        
        sent1, sent2 = input[0], input[1]
        #這裡使用mask,在後面計算attention的時候,讓其忽略pad的位置
        mask1, mask2 = sent1.eq(0), sent2.eq(0)

        # embeds: batch_size * seq_len => batch_size * seq_len * batch_size
        x1 = self.embeds(sent1)
        x2 = self.embeds(sent2)

        # batch_size * seq_len * dim => batch_size * seq_len * hidden_size
        output1, _ = self.lstm1(x1)
        output2, _ = self.lstm1(x2)

        # 進行Attention的操作,同時進行形狀的對齊
        # batch_size * seq_len * hidden_size
        q1_align, q2_align = self.soft_attention_align(output1, output2, mask1, mask2)

        # 拼接之後再傳入LSTM中進行處理
        # batch_size * seq_len * (8 * hidden_size)
        q1_combined = torch.cat([output1, q1_align, self.submul(output1, q1_align)], -1)
        q2_combined = torch.cat([output2, q2_align, self.submul(output2, q2_align)], -1)

        # batch_size * seq_len * (2 * hidden_size)
        q1_compose, _ = self.lstm2(q1_combined)
        q2_compose, _ = self.lstm2(q2_combined)

        # 進行Aggregate操作,也就是進行pooling
        # input: batch_size * seq_len * (2 * hidden_size)
        # output: batch_size * (4 * hidden_size)
        q1_rep = self.apply_pooling(q1_compose)
        q2_rep = self.apply_pooling(q2_compose)

		# Concate合併到一起,用來進行計算相似度
        x = torch.cat([q1_rep, q2_rep], -1)
        
   def submul(self,x1,x2):
        mul = x1 * x2
        sub = x1 - x2
        return torch.cat([sub,mul],dim=-1)

atttention的計算

實現思路:

  1. 先獲取attention_weight

  2. 在使用attention_weight和encoder_output進行相乘

    def soft_attention_align(self, x1, x2, mask1, mask2):
        '''
        x1: batch_size * seq_len_1 * hidden_size
        x2: batch_size * seq_len_2 * hidden_size
        mask1:x1中pad的位置為1,其他為0
        mask2:x2中pad 的位置為1,其他為0
        '''
        # attention: batch_size * seq_len_1 * seq_len_2
        attention_weight = torch.matmul(x1, x2.transpose(1, 2))
        #mask1 : batch_size,seq_len1
        mask1 = mask1.float().masked_fill_(mask1, float('-inf'))
        #mask2 : batch_size,seq_len2
        mask2 = mask2.float().masked_fill_(mask2, float('-inf'))

        # weight: batch_size * seq_len_1 * seq_len_2
        weight1 = F.softmax(attention_weight + mask2.unsqueeze(1), dim=-1)
        #batch_size*seq_len_1*hidden_size
        x1_align = torch.matmul(weight1, x2)
        
        #同理,需要對attention_weight進行permute操作
        weight2 = F.softmax(attention_weight.transpose(1, 2) + mask1.unsqueeze(1), dim=-1)
        x2_align = torch.matmul(weight2, x1)

Pooling實現

池化的過程有一個視窗的概念在其中,所以max 或者是average指的是視窗中的值取最大值還是取平均估值。整個過程可以理解為拿著視窗在源資料上取值

視窗有視窗大小(kernel_size,視窗多大)和步長(stride,每次移動多少)兩個概念

def apply_pooling(self, x):
    # input: batch_size * seq_len * (2 * hidden_size)
    #進行平均池化
    p1 = F.avg_pool1d(x.transpose(1, 2), x.size(1)).squeeze(-1)
    #進行最大池化
    p2 = F.max_pool1d(x.transpose(1, 2), x.size(1)).squeeze(-1)
    # output: batch_size * (4 * hidden_size)
    return torch.cat([p1, p2], 1)

3.2.2 相似度計算部分

相似度的計算我們可以使用一個傳統的距離計算公式,或者是exp的方法來實現,但是其效果不一定好,所以這裡我們使用一個深層的神經網路來實現,使用pytorch中的Sequential物件來實現非常簡單

self.fc = nn.Sequential(
    nn.BatchNorm1d(self.hidden_size * 8),
    
    nn.Linear(self.hidden_size * 8, self.linear_size),
    nn.ELU(inplace=True),
    nn.BatchNorm1d(self.linear_size),
    nn.Dropout(self.dropout),
    
    nn.Linear(self.linear_size, self.linear_size),
    nn.ELU(inplace=True),
    nn.BatchNorm1d(self.linear_size),
    nn.Dropout(self.dropout),
    
    nn.Linear(self.linear_size, 2),
    nn.Softmax(dim=-1)
)

通過下圖可以看出他和RELU的區別,RELU在小於0的位置全部為0,但是ELU在小於零的位置是從0到-1的。可以理解為正常的資料彙總難免出現噪聲,小於0的值,而RELU會直接把他處理為0,認為其實正常值,但是ELU卻會保留他,所以ELU比RELU更有魯棒性

3.2.3 損失函式部分

在孿生神經網路中我們經常會使用對比損失(Contrastive Loss),作為損失函式,對比損失是Yann LeCun提出的用來判斷資料降維之後和源資料是否相似的問題。在這裡我們用它來判斷兩個句子的表示是否相似。

對比損失的計算公式如下:

但是前面我們已經計算出了相似度,所以在這裡我們有兩個操作

  1. 使用前面的相似度的結果,把整個問題轉化為分類(相似,不相似)的問題,或者是轉化為迴歸問題(相似度是多少)

  2. 不是用前面相似度的計算結果部分,只用編碼之後的結果,然後使用對比損失。最後在獲取距離的時候使用歐氏距離來計算器相似度

使用DNN+均方誤差來計算得到結果

def train(model,optimizer,loss_func,epoch):
    model.tarin()
        for batch_idx, (q,simq,q_len,simq_len,sim) in enumerate(train_loader):
            optimizer.zero_grad()
        	output = model(q.to(config.device),simq.to(config.device))
            loss = loss_func(output,sim.to(config.deivce))
            loss.backward()
            optimizer.step()
            if batch_idx%100==0:
            	print("...")
            	torch.save(model.state_dict(), './DNN/data/model_paramters.pkl')
                torch.save(optimizer.state_dict(),"./DNN/data/optimizer_paramters.pkl")

            
model = SiameseNetwork().cuda()
loss =  torch.nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
for epoch in range(1,config.epoch+1):
    train(model,optimizer,loss,epoch)

使用對比損失來計算得到結果

#contrastive_loss.py
import torch
import torch.nn
class ContrastiveLoss(torch.nn.Module):
    """
    Contrastive loss function.
    """

    def __init__(self, margin=1.0):
        super(ContrastiveLoss, self).__init__()
        self.margin = margin

    def forward(self, x0, x1, y):
        # 歐式距離
        diff = x0 - x1
        dist_sq = torch.sum(torch.pow(diff, 2), 1)
        dist = torch.sqrt(dist_sq)

        mdist = self.margin - dist
        #clamp(input,min,max),和numpy中裁剪的效果相同
        dist = torch.clamp(mdist, min=0.0)
        loss = y * dist_sq + (1 - y) * torch.pow(dist, 2)
        loss = torch.sum(loss) / 2.0 / x0.size()[0]
        return loss

之後只需要把原來的損失函式改為當前的損失函式即可

 

3.3 不同模型的結果對比

================================================================

程式碼封裝和對外提供介面

目標

  1. 能夠完成封裝的程式碼

  2. 能夠使用grpc對外提供介面

  3. 能夠使用supervisord完成服務的管理

1. 完成程式碼的封裝

程式碼封裝過程中,需要注意,在整個結構中,我們有很多的結算結果是dump到本地的,為了防止後續每次的重複計算。所以laod的結果,應該提前載入到內容,而不是每次呼叫load義詞

1.1 完成意圖識別程式碼封裝

完成判斷使用者意圖的程式碼,即在使用fasttext的模型,判斷使用者輸入句子的分類

import fastText
import re
from lib import jieba_cut

fc_word_mode = fastText.load_model("./classify/data/ft_classify.model")
fc_word_mode = fastText.load_model("./classify/data/ft_classify_words.model")



def is_QA(sentence_info):
    python_qs_list = [" ".join(sentence_info["cuted_sentence"])]
    result = fc_word_mode.predict(python_qs_list)
	
    python_qs_list = [" ".join(sentence_info["cuted_word_sentence"])]
    words_result = fc_word_mode.predict(python_qs_list)
    for index, (label,acc,word_label,word_acc) in enumerate(zip(*result,*words_result)):
        label = label[0]
        acc = acc[0]
        word_label = word_label[0]
        word_acc = word_acc[0]
        #以label_qa為準,如果預測結果是label_chat,則label_qa的概率=1-labele_chat
        if label == "__label__chat":
            label = "__label__QA"
            acc = 1-acc
        if word_label == "__label__chat":
            word_label = "__label__QA"
            word_acc = 1 - word_acc
        if acc>0.95 or word_acc>0.95:
            #是QA
            return True
        else:
            return False

1.2 完成對chatbot程式碼的封裝

提供predict的介面

"""
準備閒聊的模型
"""
import pickle
from lib import jieba_cut
import numpy as np
from chatbot import Sequence2Sequence

class Chatbot:
    def __init__(self,ws_path="./chatbot/data/ws.pkl",save_path="./chatbot/model/seq2seq_chatbot.ckpt"):
        self.ws_chatbot = pickle.load(open(ws_path, "rb"))
        self.save_path = save_path
		#TODO .....


    def predict(self,s):
        """
        :param s:沒有分詞的
        :param ws:
        :param ws_words:
        :return:
        """
        #TODO ...
        return ans

1.3 完成對問答系統召回的封裝

"""
進行召回的方法
"""
import os
import pickle


class Recall:
    def __init__(self,topk=20):
        # 準備問答的mode等模組
        self.topk = topk

    def predict(self,sentence):
        """
        :param sentence:
        :param debug:
        :return: [recall list],[entity]
        """
        #TODO recall
        return recall_list

    def get_answer(self,s):
        return self.QA_dict[s]

1.4 完成對問答排序模型的封裝

"""
深度學習排序
"""
import tensorflow as tf
import pickle
from DNN2 import SiamsesNetwork
from lib import jieba_cut


class DNNSort():
    def __init__(self):
        #使用詞語和單字兩個模型的均值作為最後的結果
        self.dnn_sort_words = DNNSortWords()
        self.dnn_sort_single_word = DNNSortSingleWord()

    def predict(self,s,c_list):
        sort1 = self.dnn_sort_words.predict(s,c_list)
        sort2 = self.dnn_sort_single_word.predict(s,c_list)
        for i in sort1:
            sort1[i] = (sort1[i]+ sort2[i])/2
        sorts = sorted(sort1.items(),key=lambda x:x[-1],reverse=True)
        return sorts[0][0],sorts[0][1]

class DNNSortWords:
    def __init__(self,ws_path="./DNN2/data/ws_80000.pkl",save_path="./DNN2/model_keras/esim_model_softmax.ckpt"):
        self.ws = pickle.load(open(ws_path, "rb"))
        self.save_path = save_path
		#TOOD ...
        
    def predict(self,s,c_list):
        """
        :param s:沒有分詞的
        :param c_list: 帶比較的列表
        :param ws:
        :param ws_words:
        :return:
        """
        #TOOD ...
        return sim_dict

class DNNSortSingleWord:
    def __init__(self,ws_path="./DNN2/data/ws_word.pkl",save_path="./DNN2/data/esim_word_model_softmax.ckpt"):
        self.ws = pickle.load(open(ws_path, "rb"))
        self.save_path = save_path
        #TOOD ...

    def predict(self,s,c_list):
        """
        :param s:沒有分詞的
        :param c_list: 帶比較的列表
        :param ws:
        :param ws_words:
        :return:
        """
		#TOOD ...
        return sim_dict

1.5 實現對聊天記錄的儲存

不同的使用者,連續10分鐘內的對話認為是一輪對話,如果10分還沒有下一次對話,認為該輪對話結束,如果10分鐘後開始對話,認為是下一輪對話。是要是為了儲存不同輪中的聊天主題,後續可以實現基本的對話管理。比如使用者剛問了python相關的問題,後續如果問題中不帶主體,那麼就把redis中的python作為其主體

主要實現邏輯為:

  1. 使用redis儲存使用者基本的資料

  2. 使用mongodb儲存對話記錄

具體思路如下:

  1. 根據使用者id,獲取對話id,根據對話id判斷當前的對話是否存在

  2. 如果對話id存在:

    1. 更新對話的entity,上一次對話的時間,設定對話id的過期時間

    2. 儲存資料到mongodb

  3. 如果對話id不存在:

    1. 建立使用者的基礎資訊(user_id,entity,對話時間)

    2. 把使用者的基礎資訊存入redis,同時設定對話id和過期時間

    3. 儲存資料到mongodb中

"""
獲取,更新使用者的資訊
"""
from pymongo import MongoClient
import redis
from uuid import uuid1
import time
import json

"""
### redis
{
user_id:"id",
user_background:{}
last_entity:[]
last_conversation_time:int(time):
}

userid_conversation_id:""

### monodb 儲存對話記錄
{user_id:,conversion_id:,from:user/bot,message:"",create_time,entity:[],attention:[]}
"""

HOST = "localhost"
CNVERSION_EXPERID_TIME = 60 * 10  # 10分鐘,連續10分鐘沒有通訊,意味著會話結束


class MessageManager:
    def __init__(self):
        self.client = MongoClient(host=HOST)
        self.m = self.client["toutiao"]["dialogue"]
        self.r = redis.Redis(host=HOST, port=6379, db=10)

    def last_entity(self, user_id):
        """最近一次的entity"""
        return json.loads(self.r.hget(user_id, "entity"))

    def gen_conversation_id(self):
        return uuid1().hex

    def bot_message_pipeline(self, user_id, message):
        """儲存機器人的回覆記錄"""
        conversation_id_key = "{}_conversion_id".format(user_id)
        conversation_id = self.user_exist(conversation_id_key)
        if conversation_id:
            # 更新conversation_id的過期時間
            self.r.expire(conversation_id_key, CNVERSION_EXPERID_TIME)
            data = {"user_id": user_id,
                    "conversation_id": conversation_id,
                    "from": "bot",
                    "message": message,
                    "create_time": int(time.time()),
                    }
            self.m.save(data)

        else:
            raise ValueError("沒有會話id,但是機器人嘗試回覆....")

    def user_message_pipeline(self, user_id, message, create_time, attention, entity=[]):
        # 確定使用者相關的資訊
        # 1. 使用者是否存在
        # 2.1 使用者存在,返回使用者的最近的entity,存入最近的對話
        # 3.1 判斷是否為新的對話,如果是新對話,開啟新的回話,update使用者的對話資訊
        # 3.2 如果不是新的對話,update使用者的對話資訊
        # 3. 更新使用者的基本資訊
        # 4  返回使用者相關資訊
        # 5. 呼叫預測介面,發來對話的結構

        # 要儲存的data資料,缺少conversation_id
        data = {
            "user_id": user_id,
            "from": "user",
            "message": message,
            "create_time": create_time,
            "entity": json.dumps(entity),
            "attention": attention,
        }

        conversation_id_key = "{}_conversion_id".format(user_id)
        conversation_id = self.user_exist(conversation_id_key)
        print("conversation_id",conversation_id)
        if conversation_id:
            if entity:
                # 更新當前使用者的 last_entity
                self.r.hset(user_id, "last_entity", json.dumps(entity))
            # 更新最後的對話時間
            self.r.hset(user_id, "last_conversion_time", create_time)
            # 設定conversation id的過期時間
            self.r.expire(conversation_id_key, CNVERSION_EXPERID_TIME)

            # 儲存聊天記錄到mongodb中
            data["conversation_id"] = conversation_id

            self.m.save(data)
            print("mongodb 儲存資料成功")

        else:
            # 不存在
            user_basic_info = {
                "user_id": user_id,
                "last_conversion_time": create_time,
                "last_entity": json.dumps(entity)
            }
            self.r.hmset(user_id, user_basic_info)
            print("redis存入 user_basic_info success")
            conversation_id = self.gen_conversation_id()
            print("生成conversation_id",conversation_id)

            # 設定會話的id
            self.r.set(conversation_id_key, conversation_id, ex=CNVERSION_EXPERID_TIME)
            # 儲存聊天記錄到mongodb中
            data["conversation_id"] = conversation_id
            self.m.save(data)
            print("mongodb 儲存資料成功")


    def user_exist(self, conversation_id_key):
        """
        判斷使用者是否存在
        :param user_id:使用者id
        :return:
        """
        conversation_id = self.r.get(conversation_id_key)
        if conversation_id:
            conversation_id = conversation_id.decode()
        print("load conversation_id",conversation_id)
        return conversation_id

2. 使用GRPC對外提供服務

2.1 安裝grpc相關環境

gRPC 的安裝:`pip install grpcio`
安裝 ProtoBuf 相關的 python 依賴庫:`pip install protobuf`
安裝 python grpc 的 protobuf 編譯工具:`pip install grpcio-tools`

2.2 定義GRPC的介面

//chatbot.proto 檔案
syntax = "proto3";

message ReceivedMessage {
    string user_id = 1; //使用者id
    string user_message = 2; //當前使用者傳遞的訊息
    int32 create_time = 3; //當前訊息傳送的時間
}

message ResponsedMessage {
    string user_response = 1; //返回給使用者的訊息
    int32 create_time = 2; //返回給使用者的時間
}

service ChatBotService {
  rpc Chatbot (ReceivedMessage) returns (ResponsedMessage);
}

2.3 編譯生成protobuf檔案

使用下面的命令編譯,得到chatbot_pb2.pychatbot_pb2_grpc.py檔案

python -m grpc_tools.protoc -I. –python_out=. –grpc_python_out=. ./chatbot.proto

2.4 使用grpc提供服務

import dialogue
from classify import is_QA
from dialogue.process_sentence import process_user_sentence

from chatbot_grpc import chatbot_pb2_grpc
from chatbot_grpc import chatbot_pb2
import time



class chatServicer(chatbot_pb2_grpc.ChatBotServiceServicer):

    def __init__(self):
        #提前載入各種模型
        self.recall = dialogue.Recall(topk=20)
        self.dnnsort = dialogue.DNNSort()
        self.chatbot = dialogue.Chatbot()
        self.message_manager = dialogue.MessageManager()

    def Chatbot(self, request, context):
        user_id = request.user_id
        message = request.user_message
        create_time = request.create_time
        #對使用者的輸出進行基礎的處理,如分詞
        message_info = process_user_sentence(message)
        if is_QA(message_info):
            attention = "QA"
            #實現對對話資料的儲存
            self.message_manager.user_message_pipeline(user_id, message, create_time, attention, entity=message_info["entity"])
            recall_list,entity = self.recall.predict(message_info)
            line, score = self.dnnsort.predict(message,recall_list)
            if score > 0.7:
                ans = self.recall.get_answer(line)
                user_response = ans["ans"]

            else:
                user_response = "不好意思,這個問題我還沒學習到..."
        else:
            attention = "chat"
            # 實現對對話資料的儲存
            self.message_manager.user_message_pipeline(user_id,message,create_time,attention,entity=message_info["entity"])
            user_response = self.chatbot.predict(message)

        self.message_manager.bot_message_pipeline(user_id,user_response)

        user_response = user_response
        create_time = int(time.time())
        return chatbot_pb2.ResponsedMessage(user_response=user_response,create_time=create_time)

def serve():
    import grpc
    from concurrent import futures
    # 多執行緒伺服器
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    # 註冊本地服務
    chatbot_pb2_grpc.add_ChatBotServiceServicer_to_server(chatServicer(), server)
    # 監聽埠
    server.add_insecure_port("[::]:9999")
    # 開始接收請求進行服務
    server.start()
    # 使用 ctrl+c 可以退出服務
    try:
        time.sleep(1000)
    except KeyboardInterrupt:
        server.stop(0)


if __name__ == '__main__':
    serve()

3. 使用supervisor完成對服務的管理

3.1 編寫簡單的執行指令碼

#!/bin/bash

cd `$dirname`|exit 0
#source activate ds
python grpc_predict.py

新增可執行許可權:chmod +x 檔名

3.2 安裝、配置supervisor

supervisor現在的官方版本還是python2的,但是可以使用下面的命令安裝python3版本

pip3 install git+https://github.com/Supervisor/supervisor    

完成supervisor的配置檔案的編寫,conf中使用分號作為註釋符號

  ;conf.d
  [program:chat_service]
  
  command=/root/chat_service/run.sh  ;執行的命令
  
  stdout_logfile=/root/chat_service/log/out.log ;log的位置
  
  stderr_logfile=/root/chat_service/log/error.log  ;錯誤log的位置
  
  directory=/root/chat_service  ;路徑
  
  autostart=true  ;是否自動啟動
  
  autorestart=true  ;是否自動重啟
  
  startretries=10 ;失敗的最大嘗試次數

在supervisor的基礎配置中新增上述配置檔案

;/etc/supervisord/supervisor.conf 
[include]
files=/root/chat_service/conf.d

執行supervisord

supervisord -c /etc/supervisord/supervisor.conf

 

相關文章