NLP实践

您所在的位置:网站首页 发表论文是什么短语类型的词语 NLP实践

NLP实践

2024-07-09 11:22| 来源: 网络整理| 查看: 265

NLP实践——基于SIFRank的中文关键短语抽取 0. 本文介绍1. 运行环境2. 项目目录3. 代码实现3.1 utils3.2 初始化各类组件3.2.1 标点和停用词3.2.2 预训练词汇权重3.2.3 分词/词性标注模型3.2.4 候选短语抽取模型3.2.5 词形还原模型3.2.6 编码模型 3.3 建立关键短语抽取模型3.4 抽取应用 4. 改进4.1 增加候选关键短语4.2 自监督训练

0. 本文介绍

本文在《SIFRank: A New Baseline for Unsupervised Keyphrase Extraction Based on Pre-Trained Language Model》的基础上,借鉴原作者的思想,重写实现了一个好用的中文关键短语抽取工具。 首先声明一下,这篇论文我并没有看过,所有的理解全都是基于作者开源出来的代码,因而不保证所有的思想都与原作者保持一致。

这篇论文是一个抽取式的关键短语模型,相比近两年备受关注的生成式关键短语模型,其技术理念已经相对落后,但是在实际应用的生产环境中,尤其是对于无监督的垂直领域,我们更关心的是模型的可解释性以及抽取结果的可控性,因而抽取式的模型相比生成式,能够更加让我感到安心,这也是选择这篇论文作为参考的主要原因。在尝试这个思路之前,也对textrank,yake,autophrasex,UCphrase等关键短语抽取工具进行了尝试,但是效果都不太理想。

下面贴出原项目的地址: https://github.com/yukuotc/SIFRank_zh

原项目的时间比较久,其中所应用到的elmo编码器的预训练模型的下载地址已经失效,并且词性标注模型也比较旧了,所以在此项目的基础上,我从中借鉴了一部分代码,并参考作者的思路,提出并实现了自己的解决方法,主要做出的修改如下:

将编码模型从elmo替换成了以Bert为代表的预训练模型,可更好地对候选短语进行表征 编码模型支持输入文档最大长度由512扩展为1024 将词性模型从thulac替换成了ltp4,用gpu运行,运行效率提高为原来的15倍左右 提供了自定义候选短语生成方法,根据特定场景定制以提高召回 对自监督的领域迁移方法进行了测试

熟悉我写作风格的同学们应该比较了解,我很少进行理论介绍,我的博客主要从易用的角度,关注一个具体功能的实现,接下来我将从运行环境开始讲起,介绍如何实现这一关键短语抽取模型。

1. 运行环境

首先介绍一下环境配置,我的运行环境如下:

torch 1.8.1 ltp 4.1.4 thulac 0.2.1 nltk 3.5 transformers 4.9.2 sentence-transformers 2.0.0

其中,

thulac是参考原作者的环境,如果完全按照我的方法去做,不考虑原作者的方法,可以不安装;sentence-transformers是用于自监督训练,如果对领域迁移不感兴趣,可以不安装;transformers高版本是sentence-transformers的要求,如果不安装后者,估计前者4.0以上即可;ltp最好采用4.1或以上版本,其新版与旧版在效率和准确度上都有很大的差异;torch满足相应版本的ltp和transformers即可;nltk的版本相对随意,一般也不会与其他模块冲突。 2. 项目目录

然后介绍一下项目目录。建立一个项目根目录keyphrase_extractor,在此目录下建立一个jupyter笔记或py文件,建立一个utils.py(其中的内容后边会介绍),以及一个文件夹resources;

resources中,建立一个ner_usr_dict.txt,其中存放分词时的用户自定义实体表,每行写一个实体,例如:

南京市长 江大桥

这个文件的作用是,让分词模型在分词的时候,把“南京市长江大桥”分为[“南京市长”, “江大桥”],而非[“南京市”, “长江大桥”]。

然后去原项目中,下载auxiliary_data下的dict.txt,放在我们的resources下,命名为pretrained_weight_dict.txt。

再去huggingface下载一个你觉得顺眼的模型,比如bert-base,我这里用的例子是electra,然后把整个模型的所有文件放在resources中的一个目录下。(注意:不要用基于Roberta的模型,Roberta的tokenizer比较特殊,我没有进行适配) hf

hf2

全部准备好之后,整个项目目录应该是这个样子:

keyphrase_extractor |--keyphrase_extract.ipynb # 下面所有的代码放进这个笔记 |--utils.py # 辅助函数 |--resources |--ner_usr_dict.txt # 自定义实体表 |--pretrained_weight_dict.txt # 预训练词汇权重 |--chinese-electra-180g-small-discriminator # electra 预训练模型 |--config.json |--tokenizer_config.json |--tokenizer.json |--added_tokens.json |--special_tokens_map.json |--vocab.txt |--pytorch_model.bin 3. 代码实现

终于来到了喜闻乐见的代码环节,在这一环节中的所有代码,除了3.1中,全部依次丢进keyphrase_extract.ipynb中运行即可。 代码的基本逻辑我随手花了一个图,同学们凑合着看。 流程图

3.1 utils

首先完善一下我们的辅助类函数,打开utils.py,加入以下三个函数:

get_word_weight:用于获取词权重process_long_input:用于将bert支持的长度从512扩展为1024rematch:用于token-level到char-level的匹配

这三个函数是到处借鉴来的,其中1是本项目中改写的,2是此论文所述项目中搬来的,3是从bert4keras中搬来的。

import numpy as np import unicodedata, re import torch import torch.nn.functional as F def get_word_weight(weightfile="", weightpara=2.7e-4): """ Get the weight of words by word_fre/sum_fre_words :param weightfile :param weightpara :return: word2weight[word]=weight : a dict of word weight """ if weightpara = 2): word2fre[word_fre[0]] = float(word_fre[1]) sum_fre_words += float(word_fre[1]) else: print(line) for key, value in word2fre.items(): word2weight[key] = weightpara / (weightpara + value / sum_fre_words) # word2weight[key] = 1.0 #method of RVA return word2weight def process_long_input(model, input_ids, attention_mask, start_tokens, end_tokens): """ Parameters ---------- model: 编码模型 input_ids: (b, l) attention_mask: (b, l) start_tokens: 对bert而言就是[101] end_tokens: [102] Returns ------- """ # Split the input to 2 overlapping chunks. Now BERT can encode inputs of which the length are up to 1024. n, c = input_ids.size() start_tokens = torch.tensor(start_tokens).to(input_ids) # 转化为tensor放在指定卡上 end_tokens = torch.tensor(end_tokens).to(input_ids) len_start = start_tokens.size(0) # 1 len_end = end_tokens.size(0) # 1 if bert , 2 if roberta if c assert len(token_lens) == len(target_tokens), \ Exception("Token_lens and target_tokens shape unmatch: {} vs {}.".format(len(token_lens), len(target_tokens))) ## assert len(embedding_list) == len(target_tokens), \ Exception("Result embedding list must have same length as target.") return embedding_list def _get_weight_list(self, target_tokens): """ 获取weight列表 :param target_tokens: list: tokenize_and_postag_model对当前输入的分词结果 :return weight_list: list of float: 每个token对应的预训练权重列表 """ weight_list = [] _max = 0. for token in target_tokens: token = token.lower() if token in self.stop_words or token in self.punctuations: weight = 0. elif token in self.word2weight_pretrain: weight = word2weight_pretrain[token] else: # 如果OOV,返回截至当前句中最大的token weight = _max _max = max(weight, _max) weight_list.append(weight) return weight_list def _get_candidate_list(self, target_tokens, target_poses): """ 用词性正则抽取候选关键短语列表 :param target_tokens: list: tokenize_and_postag_model对当前输入的分词结果 :param target_poses: list: tokenize_and_postag_model对当前输入词性标注结果 :return candidates: list of tuples like: ('自然语言', (5, 7)) NOTE: tuple[1]是在target_tokens中的span,对target_tokens索引,得到tuple[0] """ assert len(target_tokens) == len(target_poses) tokens_tagged = [(tok, pos) for tok, pos in zip(target_tokens, target_poses)] candidates = self.extractor.extract_candidates(tokens_tagged) return candidates def _extract_keyphrase(self, candidates, weight_list, embedding_list, max_keyphrase_num): """ 对候选的关键短语计算与原文编码的相似度,获取关键短语 :param candidates: list of tuples: 候选关键短语list :param weight_list: list of float: 每个token的预训练权重列表 :param embedding_list: list of array: 每个token的编码结果 :param max_keyphrase_num: int: 最多保留的关键词个数 :return key_phrases: list of tuple: [(k1, 0.9), ...] """ assert len(weight_list) == len(embedding_list) # 获取每个候选短语的编码 candidate_embeddings_list = [] for cand in candidates: cand_emb = self.get_candidate_weight_avg(weight_list, embedding_list, cand[1]) candidate_embeddings_list.append(cand_emb) # 计算候选短语与原文的相似度 sent_embeddings = self.get_candidate_weight_avg(weight_list, embedding_list, (0, len(embedding_list))) sim_list = [] for i, emb in enumerate(candidate_embeddings_list): sim = float(pytorch_cos_sim(sent_embeddings, candidate_embeddings_list[i]).squeeze().numpy()) sim_list.append(sim) # 对候选短语归并,词根相同的短语放在一起 dict_all = {} for i, cand in enumerate(candidates): if self.lemma_model: cand_lemma = self.lemma_model.lemmatize(cand[0].lower()).replace('▲', ' ') else: cand_lemma = cand[0].lower().replace('▲', ' ') if cand_lemma in dict_all: dict_all[cand_lemma].append(sim_list[i]) else: dict_all[cand_lemma] = [sim_list[i]] # 对归并结果求平均 final_dict = {} for cand, sim_list in dict_all.items(): sum_sim = sum(sim_list) final_dict[cand] = sum_sim / len(sim_list) return sorted(final_dict.items(), key=lambda x: x[1], reverse=True)[: max_keyphrase_num] def __call__(self, text, max_keyphrase_num): """ 抽取关键词 :param text: str: 待抽取原文 :param max_keyphrase_num: int: 最多保留的关键词个数 :return key_phrases: list of tuple: [(k1, 0.9), ...] """ text = self.preprocess_input_text(text) t0 = time.time() ## ## ## ## ## return key_phrases @staticmethod def get_candidate_weight_avg(weight_list, embedding_list, candidate_span): """ 获取一个候选词的加权表征 :param weight_list: list of float: 每个token的预训练权重列表 :param embedding_list: list of array: 每个token的编码结果 :param candidate_span: tuple: 候选短语的start和end """ assert len(weight_list) == len(embedding_list) start, end = candidate_span num_words = end - start embedding_size = embedding_list[0].shape[0] sum_ = np.zeros(embedding_size) for i in range(start, end): tmp = embedding_list[i] * weight_list[i] sum_ += tmp return sum_ @staticmethod def preprocess_input_text(text): """ 对输入原文进行预处理,主要防止两个tokenizer对齐时出现问题 """ text = text.lower() # 全部判断过于耗时 # text = ''.join(char for char in text if char in self.encoding_tokenizer.vocab) text = text.replace('“', '"').replace('”', '"') text = text.replace('‘', "'").replace('’', "'") text = text.replace('⁃', '-') text = text.replace('\u3000', ' ').replace('\n', ' ') text = text.replace(' ', '▲') # text = text.replace(' ', '¤') return text[: 1024]

注意,在上面的类中调用了sentence-transformer中的pytorch_cos_sim方法计算两个张量之间的余弦相似度,如果没有安装这个包,可以自己写个方法实现余弦相似度的计算,这个不难,可以直接百度到。

3.4 抽取应用

将上面的大类实例化:

keyphrase_extractor = SIFRank(tokenize_and_postag_model=ltp_pos_model, candidate_extractor=candidate_extractor, lemma_model=lemma_model, encoding_model=electra_model, encoding_tokenizer=electra_tokenizer, encoding_pooling='mean', encoding_device='cuda:1', word2weight_pretrain=word2weight_pretrain, stop_words=stop_words, punctuations=punctuations)

然后对输入的text,调用:

keyphrase_extractor(text, max_keyphrase_num=10)

即可返回关键短语的降序排列,以及每个关键短语对应的得分。

4. 改进 4.1 增加候选关键短语

候选关键短语是通过正则的方式对词性进行匹配得到的,其关键代码在这一句:

grammar = """ NP: {*|} # Adjective(s)(optional) + Noun(s)"""

通过修改正则语句,我们可以获得自己想要的候选短语。例如,我希望拿到*'"花岗岩"超声速反舰导弹*这样的短语作为关键短语,通过观察词性发现,这类短语的词性构成是:引号+名词+引号+若干名词,翻译成正则语句就是:

*

把它拼接到原来的语句上:

grammar = """ NP: {*||*}"""

然后看一下修改之后得效果: (我用的例子说不过审,给我下架了,只能删掉了)

4.2 自监督训练

SimCSE等自监督训练可以参考我之前的这篇博客,我用SimCSE在6000条军事新闻数据上随便训练了一下,效果并不好。

(这里也删掉了,没办法)

关于mask language model的训练,可以参考huggingface官方的文档,我最近可能会整理一版比较方便的代码。如果整理了,可能会更新在博客上。

以上就是本期的全部内容了,总的来说SIFRank这个工具虽然没有那么“智能”,但可以充分做到可控,使用者对抽取结果可以从多方面进行干预和调整,是一个非常好用的关键词抽取工具。

如果这篇文章对你有所帮助,记得点个免费的赞,我们下期再见。



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3