用深度學習解決NLP中的命名實體識別(NER)問題(深度學習入門項目)

本文源碼已經上傳至 github.: https://github.com/HuBlanker/Keras-Chinese-NER

本文主要理論依據論文:Bidirectional LSTM-CRF Models for Sequence Tagging

前言

命名實體識別(Named Entity Recognition,簡稱 NER),是指識別文本中具有特定意義的實體,主要包括人名、地名、機構名、專有名詞等。簡單的講,就是識別自然文本中的實體指稱的邊界和類別。

NER 是 NLP 領域的一個經典問題,在文本情感分析,意圖識別等領域都有應用。它的實現方式也多種多樣,從最早基於規則和詞典,到傳統機器學習到現在的深度學習。本文采用當前的經典解決方案,基於深度學習的 BiLSTM-CRF 模型來解決 NER 問題。

本文主要依據於 Bidirectional LSTM-CRF Models for Sequence Tagging 論文,並參考 github 上部分項目,實現了 基於 BilSTM-CRF 的中文文本命名實體識別,以用作 搜索中的意圖識別。 源碼中包含完整的訓練及部署代碼,還有數據集的示例。

我的目的是,使用 中文樣本訓練模型,然後在線提供預測,用於線上的搜索服務。所以本文可能對原理的介紹比較少,主要集中於 實際操作。對於 用 BiLSTM-CRF 來實現 NER 概念尚不清楚的同學,可以點擊上方的論文了解一下,或者自行搜索瞭解。

離線訓練

訓練過程分爲以下幾個部分:

  1. 處理樣本數據。
  2. 編寫代碼,包括數據處理,加載,模型搭建等。
  3. 實際訓練並評估模型。

那麼讓我們來一步一步的解決這些問題。首先是樣本數據部分。

樣本數據

我們採用的格式是 字符-label. 也就是如下面這樣,每個字符和其標籤一一對應,句子與句子之間用空行隔開。

這裏數據中的所有標籤是常見的 地名, 人名, 機構名 標籤,其中 B-LOC對應着一個地名的開始,O-LOC對應着一個地名的中間部分。O代表未識別部分,也就是Other. 其他的以此類推。

通過這樣的數據,我們可以 拿到每一個實體的邊界,進行切分之後就可以拿到有效的實體識別數據。

6       O
月      O
油      O
印      O
的      O
《      O
北      B-LOC
京      I-LOC
文      O
物      O
保      O
存      O
保      O
管      O
狀      O
態      O
之      O
調      O
查      O
報      O
告      O
》      O
,      O

調      O
查      O
範      O
圍      O
涉      O
及      O
故      B-LOC
宮      I-LOC
、      O
歷      B-LOC
博      I-LOC
、      O
古      B-ORG
研      I-ORG
所      I-ORG
、      O
北      B-LOC
大      I-LOC
清      I-LOC
華      I-LOC
圖      I-LOC
書      I-LOC
館      I-LOC
、      O
北      B-LOC
圖      I-LOC
、      O
日      B-LOC
僞      O
資      O
料      O

我本人使用的樣本是自己生成及標註的一部分,涉及到個人數據,不方便放到 github 中,因此 github 中僅有一個數據集的格式示例。

需要強調的是:對於 BiLSTM-CRF 模型解決 NER 問題來講,理論已經在論文中說的十分明白,模型搭建代碼網上也是有很多不錯的可以使用的代碼。

那麼,重中之重就是樣本的整理,當然這是一個逐步優化的過程,我們可以使用一部分樣本來訓練,之後逐步標註,或者用其他方式生成一些正確的樣本。

訓練

在 github 倉庫裏,有完整的可用於訓練的代碼,我進行了脫敏,但是完全不影響理解及執行。這裏僅大致的貼一下核心代碼。

數據編碼

首先是對數據進行編碼的代碼,通過對所有訓練數據 char 級別的編碼,來讓模型可以"認識" 我們的數據:

# 對傳入目錄下的訓練和測試文件進行 char 級別的編碼,以及加載已有的編碼文件,
# 只有在更換訓練文件之後才需要 gen, 其他時間直接 load 即可。
class Word2Id:
    def __init__(self, file):
        self.file = file

    def gen_save(self):
        data_file = [args.train_data, args.test_data]
        all_char = []
        for f in data_file:
            file = open(f, "rb")
            data = file.read().decode("utf-8")
            data = data.split("\n\n")
            data = [token.split("\n") for token in data]
            data = [[j.split() for j in i] for i in data]
            data.pop()
            all_char.extend([char[0] if char else 'unk' for sen in data for char in sen])
        chars = set(all_char)
        word2id = {char: id_ + 1 for id_, char in enumerate(chars)}
        word2id["unk"] = 0
        with open(self.file, "wb") as f:
            f.write(json.dumps(word2id, ensure_ascii=False).encode('utf-8'))

    def load(self):
        return json.load(open(self.file, 'r'))

模型搭建

2.1.4 版本的 keras,在 keras 版本里面已經包含 bilstm 模型,CRF 模型包含在 keras-contrib 中。
雙向 LSTM 和單向 LSTM 的區別是用到 Bidirectional。
模型結構爲一層 embedding 層+一層 BiLSTM+一層 CRF。

代碼不難,且加了一些關鍵註釋,如下:

# BILSTM-CRF 模型
class Ner:
    def __init__(self, vocab, labels_category, Embedding_dim=200):
        self.Embedding_dim = Embedding_dim
        self.vocab = vocab
        self.labels_category = labels_category
        self.model = self.build_model()

    # 構建模型
    def build_model(self):
        model = Sequential()
        # embedding 層
        model.add(Embedding(len(self.vocab), self.Embedding_dim, mask_zero=True))  # Random embedding
        # bilstm 層
        model.add(Bidirectional(LSTM(100, return_sequences=True)))
        # crf 層
        crf = CRF(len(self.labels_category), sparse_target=True)
        model.add(crf)
        model.summary()
        model.compile('adam', loss=crf.loss_function, metrics=[crf.accuracy])
        return model

    # 訓練方法
    def train(self, data, label, EPOCHS):
        self.model.fit(data, label, batch_size=args.batch_size, callbacks=[CallBack()], epochs=EPOCHS)

    # 加載已有的模型進行訓練
    def retrain(self, model_path, data, label, epoch):
        model = self.load_model_fromfile(model_path)
        print("load model, evaluate it.")
        loss, accuracy = model.evaluate(data, label)
        print("load model, loss = %s, acc =%s ." % (loss, accuracy))
        model.fit(data, label, batch_size=124, callbacks=[CallBack()], epochs=epoch)

    # 從給定的目錄加載一個模型
    def load_model_fromfile(self, model_path):
        crf = CRF(len(self.labels_category), sparse_target=True)
        return load_model(model_path, custom_objects={"CRF": CRF, 'crf_loss': crf.loss_function,
                                                      'crf_viterbi_accuracy': crf.accuracy})
    
    # 預測,主要用於交互式的測試某些樣本的預測結果。我個人習慣在訓練完成之後手動測試一些常見的 case,
    def predict(self, model_path, data, maxlen):
        model = self.model
        char2id = [self.vocab.get(i) for i in data]
        input_data = pad_sequences([char2id], maxlen)
        model.load_weights(model_path)
        result = model.predict(input_data)[0][-len(data):]
        result_label = [np.argmax(i) for i in result]
        return result_label

    # 測試,可以用某個測試集跑一下模型,看看效果
    def test(self, model_path, data, label):
        model = self.load_model_fromfile(model_path)
        loss, acc = model.evaluate(data, label)
        return loss, acc

加載數據

在我們用其他方式處理完數據之後,我們拿到了我們想要的格式,但是這個格式並不是可以直接被模型接受的,因此我們需要加載數據,並且進行一些處理,比如編碼或者 padding.

# 處理數據集
class DataSet:
    def __init__(self, data_path, labels):
        with open(data_path, "rb") as f:
            self.data = f.read().decode("utf-8")
        self.process_data = self.process_data()
        self.labels = labels

    def process_data(self):
        # 讀取樣本並分割
        train_data = self.data.split("\n\n")
        train_data = [token.split("\n") for token in train_data]
        train_data = [[j.split() for j in i] for i in train_data]
        train_data.pop()
        return train_data

    def generate_data(self, vocab, maxlen):
        char_data_sen = [[token[0] for token in i] for i in self.process_data]
        label_sen = [[token[1] for token in i] for i in self.process_data]
        # 對樣本進行編碼
        sen2id = [[vocab.get(char, 0) for char in sen] for sen in char_data_sen]
        # 對樣本中的標籤進行編碼
        label2id = {label: id_ for id_, label in enumerate(self.labels)}
        lab_sen2id = [[label2id.get(lab, 0) for lab in sen] for sen in label_sen]
        # padding
        sen_pad = pad_sequences(sen2id, maxlen)
        lab_pad = pad_sequences(lab_sen2id, maxlen, value=-1)
        lab_pad = np.expand_dims(lab_pad, 2)
        return sen_pad, lab_pad

進行完上線的三個步驟之後,我們基本上就可以進行訓練了。

還有一部分的功能性代碼,比如啓動參數,模型保存格式等沒有貼出來,使用的時候可以直接從 github 上看一下就好。

在** python3, keras 2.2.4** 環境下,執行 python3 model.py --mode=train, 即可開始訓練,會將模型自動保存到* model 路徑下,保存爲 H5 SavedModel *兩種格式。

評估模型

模型運行期間及每一次 epoch 運行結束,會打印響應的 loss 及 accuracy. 如下圖所示:

2019-11-25-11-33-13

此外還可以運行python3 model.py --mode=predict --input_model_dir=model來進行交互式的預測。

在線預測

離線訓練得到了效果讓我們滿意的模型之後,就是在線預測的流程了。

tensorflow 模型如何部署到線上,一直是比較花裏胡哨的,針對這種情況 Google 提供了 TensorFlow Servering,可以用一套標準化的流程,將訓練好的模型直接上線並提供服務。

tensorflow serving 介紹

TensorFlow Serving 是一個用於機器學習模型 serving 的高性能開源庫。它可以將訓練好的機器學習模型部署到線上,使用 gRPC 作爲接口接受外部調用。它支持模型熱更新與自動模型版本管理。這意味着一旦部署 TensorFlow Serving 後,不再需要爲線上服務操心,只需要關心你的線下模型訓練。

tensorflow serving 持續集成的大概流程如下:

基於 TF Serving 的持續集成框架還是挺簡明的,基本分三個步驟:

  1. 離線模型訓練
    主要包括數據的收集和清洗、模型的訓練、評測和優化。
  2. 模型上線
    將前一個步驟訓練好的模型保存爲指定的格式,之後在 TF Server 中上線;
  3. 服務使用
    客戶端通過 gRPC 和 RESTfull API 兩種方式同 TF Servering 端進行通信,並獲取服務,進行在線預測。

TF Serving 工作流程如下:
2019-11-25-13-02-08

模型保存格式

要想使用 tensorflow serving 來部署模型,需要將模型保存爲特定的格式。

如果你是使用 **keras models **構建的模型,那麼直接tf.saved_model.save(self.model, save_dir)即可。

如果你是使用 keras sequential 構建的模型,那麼使用下面的方法,可以讓你將序列模型保存爲** SavedModel **格式。

    def export_saved_model(self, saved_dir, epoch):
        model_version = epoch
        model_signature = tf.saved_model.signature_def_utils.predict_signature_def(
            inputs={'input': self.model.input}, outputs={'output': self.model.output})
        export_path = os.path.join(compat.as_bytes(saved_dir), compat.as_bytes(str(model_version)))
        builder = tf.saved_model.builder.SavedModelBuilder(export_path)
        builder.add_meta_graph_and_variables(
            sess=K.get_session(),
            tags=[tf.saved_model.tag_constants.SERVING],
            clear_devices=True,
            signature_def_map={
                tf.saved_model.signature_constants.DEFAULT_SERVING_SIGNATURE_DEF_KEY:
                    model_signature
            })
        builder.save()

加載模型

將訓練完畢的模型放到 serving 下對應的目錄,讓 serving 進行加載,模型文件樹應該如下:

2019-11-25-16-46-22.

我在服務端啓動 serving 的時候,使用瞭如下命令:

cmd="./tensorflow_model_server \
        --port=4590 \
        --rest_api_port=4591  \
        --model_config_file=model/ \
        --tensorflow_session_parallelism=40 \
        --per_process_gpu_memory_fraction=0.2"

意味着我讀取當前目錄下 model 文件夾下的模型,加載並且對外提供了 RESTFUL 服務(在 4590 端口)以及 grpc 服務(在 4591 端口).

客戶端請求

serving 對外提供了 RESTFUL 接口以及 GRPC 接口,足夠我們使用了。

RESTFUL

在命令行執行curl -d '{"inputs": [[348.0,3848.0,2557]]}' -X POST http://localhost:4591/v1/models/model:predict, 其中,inputs 是在輸出模型時定義的模型輸入數據。也就是模型簽名。

如果不確定自己的模型定義,可以使用 tensorflow 自帶的saved_model_cli.py文件來查看,首先運行find / -name="saved_model_cli.py", 找到本機上的對應文件,如果沒有,可以去下載 TensorFlow 的源碼,其中包括這個文件。

然後執行 python saved_model_cli.py show --dir model/15 --all, 就可以看到下面這樣的輸出。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-YSR7SWN6-1575207629097)(http://img.couplecoders.tech/2019-11-25-16-58-25.png)]

我的模型定義了:

名爲"input"的輸入,是一個二維的矩陣。

名爲"output"的輸出,是一個三維的矩陣。

模型返回的預測結果爲一個三維數據,其中每一個數組代表一個字符所在的標籤。

以 “王強” 爲例。

得到的結果爲 shapre=(1,2,7) 的數組,其中 1 指的是我們只輸入了一個句子,2 指的是句子的長度,7 指的是我們所有 tag 的長度。

[
    [0,1,0,0,0,0,0]
    [0,0,1,0,0,0,0]
]

標籤順序是:[O, B-PER, I-PER, B-LOC, I-LOC, B-ORG, I-ORG]

1所在的下標對應到標籤中,可以發現王強的結果是B-PER, I-PER , 也就是一個人名。

grpc

輸入輸出和 RESTFUL 是一樣的,只是方式可能有點不一樣,這裏簡單的貼一下集成 GRPC 的那塊代碼。

    public static void main(String[] args) {
        // 構造請求
        ManagedChannel channel = ManagedChannelBuilder.forAddress("192.168.1.251", 7010).usePlaintext(true).build();
        PredictionServiceGrpc.PredictionServiceBlockingStub stub = PredictionServiceGrpc.newBlockingStub(channel);
        Predict.PredictRequest.Builder predictRequestBuilder = Predict.PredictRequest.newBuilder();
        Model.ModelSpec.Builder modelSpecBuilder = Model.ModelSpec.newBuilder();
        // 你的模型的名字
        modelSpecBuilder.setName("model");
        modelSpecBuilder.setSignatureName("");
        predictRequestBuilder.setModelSpec(modelSpecBuilder);
        TensorProto.Builder tensorProtoBuilder = TensorProto.newBuilder();
        // 模型接受的數據類型
        tensorProtoBuilder.setDtype(DataType.DT_FLOAT);
        TensorShapeProto.Builder tensorShapeBuilder = TensorShapeProto.newBuilder();
        // 接受數據的 shape, 幾維的數組,每一維多少個。我的測試數據是三個。
        tensorShapeBuilder.addDim(TensorShapeProto.Dim.newBuilder().setSize(1));
        tensorShapeBuilder.addDim(TensorShapeProto.Dim.newBuilder().setSize(3));

        // 我的測試數據,這裏需要把輸入的字符串進行編碼。比如在我的編碼下,比如將 : 呼延十 編碼成下面三個數字。
        String s = "呼延十";
        List<Float> ret = new ArrayList<>();
        ret.add(348.0f);
        ret.add(3848.0f);
        ret.add(2557.0f);

        tensorProtoBuilder.setTensorShape(tensorShapeBuilder.build());
        tensorProtoBuilder.addAllFloatVal(ret);
        predictRequestBuilder.putInputs("input", tensorProtoBuilder.build());
        Predict.PredictResponse predictResponse = stub.predict(predictRequestBuilder.build());

        //  這裏拿到的是一個 (1,1,3) 的矩陣。所以我們需要把他解碼成我們想要的 tag. 涉及到你的 tag 列表。
        List<Float> output = predictResponse.getOutputsMap().get("output").getFloatValList();
        List<String> tags = Arrays.asList("O", "B-PER", "I-PER", "B-LOC", "I-LOC", "B-ORG", "i-ORG");
        List<String> rets = phraseFrom(s, output, tags);
        System.out.println(rets);

    }

    private static List<String> phraseFrom(String q, List<Float> output, List<String> tags) {
        List<List<Float>> partition = Lists.partition(output, tags.size());
        List<Integer> idx = new ArrayList<>();
        for (List<Float> floats : partition) {
            for (int j = 0; j < floats.size(); j++) {
                if (floats.get(j) == 1.0f) {
                    idx.add(j);
                    break;
                }
            }
        }
        assert q.length() != idx.size();
        // 從 query 和每個字的 tag 解析成詞語的意圖。
        StringBuilder sb = new StringBuilder();
        char[] chars = q.toCharArray();
        List<String> rets = new ArrayList<>();
        for (int i = 0; i < chars.length; i++) {
            Integer tag = idx.get(i);
            if ((tag & 1) == 1 && sb.length() != 0) {
                String item = sb.toString();
                String ret = tags.get(idx.get(i - 1));
                rets.add(ret);
                sb.setLength(0);
                sb.append(chars[i]);
            } else {
                sb.append(chars[i]);
            }
        }
        if (sb.length() != 0) {
            String ret = tags.get(idx.get(q.length() - 1));
            rets.add(ret);
        }
        return rets;
    }

效果

項目開發完成後,模型預測正確率 97%(訓練了 30 個 epoch), 線上預測與 TensorFlow serving 交互耗時 20ms.

運行環境

python 3.6.4
keras 2.2.4
tensorflow-gpu 1.14.0
JDK 1.8

相關鏈接

Bidirectional LSTM-CRF Models for Sequence Tagging

tensorflow serving 官網

bilstm-crf with tensorflow

bilstm-crf with keras


完。

ChangeLog

2019-11-227 完成

以上皆爲個人所思所得,如有錯誤歡迎評論區指正。

歡迎轉載,煩請署名並保留原文鏈接。

聯繫郵箱:[email protected]

更多學習筆記見個人博客或關注微信公衆號 < 呼延十 >------>呼延十

發佈了94 篇原創文章 · 獲贊 12 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章