Python快速自然语言处理

数据分析中往往会遇到很多文本数据,我们需要借助一些开源的工具,快速地对这些文本数据进行简单的处理与分析。本文整理了一些基于 sklearn、nltk 等工具包进行快速 NLP 处理的常用实例,不涉及 NLP 理论知识,模型与方法的选型也不是最优的,只求快速。

字符编码

Python3 开始字符在 python 解释器内部的默认编码为 unicode,unicode 编码有许多特殊的属性:

1
2
3
4
5
6
7
import unicodedata

a = "中"
b = "❤"
print(a.encode("raw_unicode_escape")) # b'\\u4e2d'
print(unicodedata.name(a)) # CJK UNIFIED IDEOGRAPH-4E2D
print(unicodedata.name(b)) # HEAVY BLACK HEART

unicode 常用的属性包括:Name(名字)、Block(所属的连续编码范围)、Plane(所属平面)、Script(书写体系) 和 Category(类别)等。如上面的代码所示,通过 unicodedata 库可以获得 unicode 字符的上述属性,一个中文字符的 Name 都是以 CJK 开头的名称,通过名字属性我们可以判断一个字符的语种甚至语义,比如某种表情符号,其 Name 就是该表情符号的英文语义。Category 的属性跟语种是无关的,大体分为 Letter, Mark, Number, Punctuation, Symbol, Seperator, Other 七大类,而每个类别下面还有进一步的二级分类,比如 Sm 就表示 Symbol + math(数学符号)。

1
2
3
4
5
6
rst = []
for char in "1a天。 ❤️":
rst.append("{}:{}".format(char, unicodedata.category(char)))

print(", ".join(rst))
# 1:Nd, a:Ll, 天:Lo, 。:Po, :Zs, ❤:So, ️:Mn

常用的类别标记有:Nd(十进制数字),P*(标点),Cc、Cf(控制字符),Zs(空格分隔符),Mn(不占空间标记,如声调)等。

有的时候,从字面上看两个字符串是一模一样的,但是其编码却不同。这是由于 unicode 对一些字符的编码存在多种形式,主要集中在带声调的文字中,比如字符 “Ç” 就有两种编码形式: U+00C7 (LATIN CAPITAL LETTER C WITH CEDILLA) 单个编码、U+0043 (LATIN CAPITAL LETTER C) 与 U+0327 (COMBINING CEDILLA) 的两个编码 。前者称为 Compose(组合)模式,后者称为 Decompose(分离)模式。为了在处理文本时能得到统一的编码,我们需要利用 unicodedata.normalize(method, text) 函数对字符进行标准化处理,标准化分为两个方式:method=”NFKC“ 将所有的文本标准化为 Compose 形式;method=”NFKD“ 将所有的文本标准化为 Decompose 形式。

1
2
3
4
import unicodedata
def strip_accents(s):
return ''.join(c for c in unicodedata.normalize('NFD', s)
if unicodedata.category(c) != 'Mn')

比如,我们要去掉声调字符,那么就按照 Decompose 形式标准化后,去除掉类别标记是 Mn 的字符即可。

文字判断

如上文所属,一个语种的字符拥有若干连续的编码范围,中文字符的编码范围分为 8 段 ,如下面的代码所示,判断一个字符是否为中文(CJK中日韩文字):

1
2
3
4
5
6
7
8
9
10
def _is_chinese_char(char):
if ((char >= 0x4E00 and char <= 0x9FFF) or
(char >= 0x3400 and char <= 0x4DBF) or
(char >= 0x20000 and char <= 0x2A6DF) or
(char >= 0x2A700 and char <= 0x2B73F) or
(char >= 0x2B740 and char <= 0x2B81F) or
(char >= 0x2B820 and char <= 0x2CEAF) or
(char >= 0xF900 and char <= 0xFAFF) or
(char >= 0x2F800 and char <= 0x2FA1F)):
return True

当然,如前文所述,也可以采用 unicodedata.name(char).startswith("CJK") 来判断。另外,判断一个字符为标点符号则可以用 unicodedata.category(char).startswith("P") 来判断。

词法分析

对于文本数据进行处理的第一步就是将文本序列转换为词的序列,即分词。中文推荐使用结巴分词:

1
2
3
4
5
6
7
8
9
10
11
import jieba

jieba.load_userdict(file_path) # 用户自定义词典格式为“word freq POS”,其中word是必需
text = "我来到北京清华大学"
seg_list = jieba.cut(text)
print("Default Mode: " + "/ ".join(seg_list)) # 精确模式
# 我/ 来到/ 北京/ 清华大学

seg_list = jieba.cut(text, cut_all=True)
print("Full Mode: " + "/ ".join(seg_list)) # 全词模式
# 我/ 来到/ 北京/ 清华/ 清华大学/ 华大/ 大学

有的时候可以进一步得到词性的信息:
1
2
3
4
5
6
import jieba.posseg as psg

seg_pos = psg.cut(text)
for x in seg_pos:
print("{}/{} ".format(x.word, x.flag), end="")
# 我/r 来到/v 北京/ns 清华大学/nt

英文可以使用 NLTK 工具包,除了分词与词性标注以外,英文有的时候还可能需要进行词根还原:

1
2
3
4
5
import nltk

text = nltk.word_tokenize("And now for something completely different.")
print(text)
# ['And', 'now', 'for', 'something', 'completely', 'different', '.']

上述的词法分析工具包因为其内部集成了词典,所以可以直接或是简单配置资源后调用即可。然而,新兴的基于 BERT 的 NLP 方法提出了全新的分词模式—— wordpiece 分词,这种分词方法将中文切分为单个字,英文按照公共前缀的方式进行切分,这样可以解决一定程度的 OOV 问题。如果我们使用已发布的 BERT 模型,则需要配合使用该模型对应的词典进行文本预处理:

1
2
3
4
from transformers import BertTokenizer

tokenizer = BertTokenizer.from_pretrained(BERT_MODEL_PATH)
tokenizer.tokenize(text)

当然,如果我们要完全基于自己的语料构建 BERT 分词词典也是可以的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# pip install tonkenizers -q
from tokenizers import BertWordPieceTokenizer

# 设置 handle_chinese_chars = False 中文也会按子串分词
tokenizer = BertWordPieceTokenizer(
clean_text=False,
handle_chinese_chars=True,
strip_accents=False,
lowercase=True,
)
# train BERT tokenizer
tokenizer.train(
file_path_lst, # 所有的语料文件路径组成的列表
vocab_size=VOCAB_MAX_SIZE,
min_frequency=VOCAB_MIN_FREQ,
show_progress=True,
special_tokens=['[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]'],
limit_alphabet=1000,
wordpieces_prefix="##"
)

vocab_dict = tokenizer.get_vocab()
sorted_lst = sorted(vocab_dict.items(), key=lambda x: x[1])
fout = open(VOCAB_FILE, 'w', encoding='utf-8')
for word, _ in sorted_lst:
fout.write("%s\n" % word)
fout.close()

构建好的 BERT 分词词典后,就可以使用 BERT 特殊的分词方式进行文本预处理了:

1
2
3
4
from tokenizers import BertWordPieceTokenizer

tokenizer = BertWordPieceTokenizer(VOCAB_FILE)
word_lst = tokenizer.tokenize("Hello word!")

特征选择

在传统的 NLP 方法中,词就是最基础的特征,基于分好词的语料统计出一个固定大小的词表,包括分词结果中的单个词与 n-grams 词组,就完成了大多数 NLP 任务的特征选择。sklearn 工具包中提供了一个记性文本特征提取的类 CountVectorize:

1
2
3
4
5
class sklearn.feature_extraction.text.CountVectorizer(input='content',
encoding='utf-8', decode_error='strict',strip_accents=None, lowercase=True,
preprocessor=None, tokenizer=None, stop_words=None,token_pattern='(?u)\b\w\w+\b',
ngram_range=(1, 1), analyzer=u'word', max_df=1.0, min_df=1,
max_features=None, vocabulary=None, binary=False, dtype=<type 'numpy.int64'>)

input 参数一般使用默认的 content,即表示我们传入的是处理好的 token 化的文本,其数据类型是一个列表且元素是若干篇文档,每篇文档是用空格间隔的 token 序列。ngram_range 用来指定 n-grams 统计的长度范围。max_df 与 min_df 用来限定 DF 的最值,使用 DF 值“掐头去尾”是一种最简单有效的特征选择方法。max_features 用来限定特征的数量,通过按照词频的降序排列截取前 max_features 个作为特征。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from sklearn.feature_extraction.text import CountVectorizer

# 四篇文章
texts = ["dog cat fish", "dog cat cat", "fish bird", 'bird']

#创建词袋数据结构
cv = CountVectorizer()
cv_fit = cv.fit_transform(texts)

print(cv.get_feature_names()) # ['bird', 'cat', 'dog', 'fish']
print(cv.vocabulary_) # {'dog': 2, 'cat': 1, 'fish': 3, 'bird': 0}

print(cv_fit)
'''
(0, 3) 1
(0, 1) 1
(0, 2) 1
(1, 1) 2
(1, 2) 1
(2, 0) 1
(2, 3) 1
(3, 0) 1
'''

定义完 CountVectorizer 类的对象,使用 fit_transform 方法进行“拟合”,就可以得到词频矩阵,矩阵元素 a[i][j] 表示 ID 为 j 的词在第 i 个文本下的词频。比起统计词频、DF 值等简单的特征选择算法,在带有标签的语料中还可以使用卡方、互信息等特征选择算法选择一定数量的词构建词汇表。可以通过 TextFeatureSelection 包来实现上述特征选择:

1
2
3
4
5
6
7
8
9
from TextFeatureSelection import TextFeatureSelection

input_doc_list=['i am very happy','i just had an awesome weekend','this is a very difficult terrain to trek. i wish i stayed back at home.','i just had lunch','Do you want chips?']
target=['Positive','Positive','Negative','Neutral','Neutral']

# 默认采用四种特征选择算法综合计算分数
fsOBJ=TextFeatureSelection(target=target,input_doc_list=input_doc_list, metric_list=['MI','CHI','PD','IG'])
result_df=fsOBJ.getScore()
print(result_df)

选择出来的特征通常需要用两个词典进行维护,一个是 word2id,一个是 id2word,以方便将文本数据 ID 化以及还原显示。

1
2
3
4
5
6
7
8
9
word2id = collections.OrderedDict()
index = 0
with open('vocab.txt') as fin:
for sline in fin:
token = sline.strip()
word2id[token] = index
index += 1

id2word = {v: k for k, v in word2id.items()}

向量空间

前文选择的一定数量的特征词构成了一个“词袋”,每个词对应一个编号;如果构建一个词袋大小维度的向量,将一篇文档中出现的词对应到其编号所指的维度上,那么就可以将一篇文档转换为一个向量。至于向量每个维度上的取值,我们一般使用 TF-IDF 值作为特征权重:

1
2
3
4
5
6
from sklearn.feature_extraction.text import TfidfTransformer 

...
transformer = TfidfTransformer()
tf_idf = transformer.fit_transform(cv_fit)
print(tf_idf.toarray())

通常我们的做法是,接着上文 CountVectorizer 生成的特征矩阵,使用 TfidfTransformer 对其进行处理,计算生成权重为 tf-idf 的矩阵。

1
2
class sklearn.feature_extraction.text.TfidfTransformer(*, norm='l2', 
use_idf=True, smooth_idf=True, sublinear_tf=False)

TfidfTransformer 也提供了一些可配置的参数,但使用默认配置就可以了。norm 参数指定归一化向量的方法可以配置为 ‘l1’ 或 ‘l2’;use_idf 表示是否使用 idf,如果是 False 则相当于固定 idf=1;smooth_idf 表示是否做平滑(即我们计算 idf 时常用的加 1 平滑);sublinear_tf 表示是否对 tf 进行次线性缩放,如果为 False 则 tf = 1 + log(tf)。

当然,上面所述的两步走的方法,也可以由 TfidfVectorizer 类一步搞定:

1
2
3
4
5
6
from sklearn.feature_extraction.text import TfidfVectorizer

tv = TfidfVectorizer()
# tv = TfidfVectorizer(vocabulary=feature_dict)
tv_fit = tv.fit_transform(texts)
print(tv_fit.toarray())

TfidfVectorizer 的初始化参数与 CountVectorizer 是一致的,如果我们通过其他算法事先选好了特征,我们也可以在初始化时指定 vocabulary 参数(dict 类型),从而按照指定的特征词袋计算tf-idf权重来生成向量空间。

相似度量

将文本转换成向量空间中的向量就可以计算彼此间的相似度,如使用欧式距离、余弦相似度作为度量,我们使用 numpy 的向量数据结构来快速计算:

1
2
3
4
5
6
7
8
9
10
11
12
import numpy as np

x1_np = np.array(x1)
x2_np = np.array(x2)

# 欧式距离
dist1 = np.sqrt(np.sum((x1_np - x2_np)**2)) # 或者:np.linalg.norm(x1_np, x2_np)
# 曼哈顿距离
dist2 = np.sum(np.abs(x1_np - x2_np))
# 余弦值
dist3 = np.dot(x1_np, x2_np)/(np.sqrt(x1_np, x1_np)*np.dot(x2_np, x2_np))
# 或者:np.dot(x1_np, x2_np)/(np.linalg.norm(x1_np)*np.linalg.norm(x2_np))

需要注意的是,欧式距离、曼哈顿距离都是大于 0 的值,值越小表示越相似;而余弦值是 [-1, 1] 的取值,其越接近 0 表示越相似。

文本聚类

文本数据中存在大量相似文本,我们希望通过文本聚类后从每个簇中适当地采样来减少待分析的样本数量,同时保持其多样性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfTransformer
from nltk.cluster.kmeans import KMeansClusterer
from nltk.cluster.util import cosine_distance

# 构建标准输入,一行一个文档,文档为空格间隔的token序列
seg_lines = []
for token_lst in corpus:
seg_lines.append(' '.join(token_lst))
# 将文本中的词语转换为词频矩阵 矩阵元素a[i][j] 表示j词在i类文本下的词频
vectorizer = CountVectorizer()
transformer = TfidfTransformer()
tfidf = transformer.fit_transform(vectorizer.fit_transform(seg_lines))
tfidf_arr = tfidf.toarray()
kmeans = KMeansClusterer(num_means=cluster_num, distance=cosine_distance)
kmeans.cluster(tfidf_arr)
# 获取分类:文档编号 -> 聚类的簇
clusters = {}
for i in range(len(tfidf_arr)):
clus = kmeans.classify(tfidf_arr[i])
if clus not in clusters:
clusters[clus] = []
clusters[clus].append(i)

我们使用前文提到到方法将文本转换为向量,并使用常用的 KMeans 聚类算法进行聚类分析。这里我们使用的是 NLTK 包中的 KMeans算法,sklearn 工具包中也有相应的聚类算法:

1
2
3
4
5
from sklearn.cluster import KMeans

km_cluster = KMeans(n_clusters=cluster_num, max_iter=300, n_init=40, init='k-means++')
result = km_cluster.fit_predict(tfidf_matrix)
print(result) # [0 1 2 1] 返回各自文本的所被分配到的类索引

其中,max_iter 是对于单次初始值计算的最大迭代次数;n_init 为重新选择初始值的次数;init 指定初始值选择的算法;n_jobs 为进程个数,为-1的时候是指默认跑满 CPU。

主题分析

不同的文档描述的是不同的主题,主题分析类似于文本聚类,它将相同主题的文档与词汇聚到一起,gensim 包里实现了主题模型 LDA,我们使用该模型来进行主题分析:

1
2
3
4
5
6
class gensim.models.ldamodel.LdaModel(corpus=None, num_topics=100, 
id2word=None, distributed=False, chunksize=2000, passes=1, update_every=1,
alpha='symmetric', eta=None, decay=0.5, offset=1.0, eval_every=10,
iterations=50, gamma_threshold=0.001, minimum_probability=0.01,
random_state=None, ns_conf=None, minimum_phi_value=0.01,
per_word_topics=False, callbacks=None, dtype=<class 'numpy.float32'>)

这里面 alpha 和 eta 是 LDA 模型中的两个超参数,可以完全使用默认值;chunksize 设置同时训练的文档数。值得注意的是,passes 参数才是遍历所有文档的次数(类似 epochs),而 iteration 是内部计算时的迭代次数,这两个参数的设置容易搞混。我们需要重点关注的是构建 LDA 模型时传入的数据格式,好在 gensim 包也提供了处理语料的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import gensim
import gensim.corpora as corpora

# 预处理后的文档是一个二维列表
texts = [['food', 'good'], ['environment', 'very', 'quiet'], ['food', 'very', 'delicious']]

# 创建词典
id2word = corpora.Dictionary(texts)
# 文本 ID 化
corpus = [id2word.doc2bow(text) for text in texts]
# 元组(单词ID,在文档中的词频)组成的列表对应一篇文档
print(corpus) # [[(0, 1), (1, 1)], [(2, 1), (3, 1), (4, 1)], [(0, 1), (4, 1), (5, 1)]]

# 构建 LDA 模型
lda_model = gensim.models.ldamodel.LdaModel(corpus=corpus, id2word=id2word, num_topics=5,
chunksize=100, passes=10)
print(lda_model.print_topics(num_words=3))
print(lda_model.get_document_topics(corpus[0]))
'''
[(0, '0.306*"very" + 0.167*"environment" + 0.167*"quiet"'), (1, '0.167*"food" + 0.167*"very" + 0.167*"delicious"'), (2, '0.167*"food" + 0.167*"very" + 0.167*"delicious"'), (3, '0.167*"food" + 0.167*"delicious" + 0.167*"quiet"'), (4, '0.376*"food" + 0.374*"good" + 0.062*"delicious"')]
[(0, 0.06708595), (1, 0.06671472), (2, 0.066714704), (3, 0.06671471), (4, 0.73276997)]
'''

我们事先完成对文档的预处理,包括分词、过滤停用词等,形成一个二维列表,里面一维是单个文档的词的序列,外面一维是所有的文档。训练好的 LDA 模型可以通过输出主题与关键词(每个关键词属于当前主题的权重)来确定每个主题具体的语义,另外也可以输出具体的某篇文档的主题分布。

表征学习

未标注的语料是广泛可获取的,通过在未标注的语料上预训练一个表征模型,可以更好地得到每个单词的语义表征,一种快速且有效的方法就是使用本领域的语料训练一个 word2vec 模型,gensim 包提供了所有操作的封装:

1
2
3
4
5
6
7
class gensim.models.word2vec.Word2Vec(sentences=None, corpus_file=None, 
vector_size=100, alpha=0.025, window=5, min_count=5, max_vocab_size=None,
sample=0.001, seed=1, workers=3, min_alpha=0.0001, sg=0, hs=0, negative=5,
ns_exponent=0.75, cbow_mean=1, hashfxn=<built-in function hash>, epochs=5,
null_word=0, trim_rule=None, sorted_vocab=1, batch_words=10000,
compute_loss=False, callbacks=(), comment=None, max_final_vocab=None,
shrink_windows=True)

这里通常需要设置的参数有:size 是词向量的维度;window 是词向量训练时上下文扫描窗口的大小,即前后各 window 个词;min-count 是最低频次,即语料中频次小于该值的词会被丢弃;wokers 是训练的进程数;sg 是 word2vec 训练的算法模式,设置为 1 表示 skip-gram 算法,0 表示 CBOW 算法。相应的 hs 参数如果为 0 则表示层次 softmax 训练算法,1 表示 negative-sampling 算法;ecpochs 设置迭代次数。sentences 是训练语料,它本身可以传入一个二维列表(文档 -> 词序列),但是一般用来预训练的语料通常规模较大,我们建议使用 gensim 封装好的以迭代器模式读取文件的接口:

1
2
3
4
5
6
import gensim

sentences = gensim.models.word2vec.LineSentence(input_file)
model = gensim.models.Word2Vec(sentences, size=60, min_count=1, sg=0, workers=16)
model.save(output_file)
model.wv.save_word2vec_format(output_file + '.vector', binary=False)

按照上面的代码,输入的文件需要是提前处理好的,一行一篇文章,每句是以空格分隔的 token 序列。保存模型时,如果考虑保存明文格式以便提供给其他编程语言调用,可以使用 save_word2vec_format 函数。

1
2
model = gensim.models.Word2Vec.load("word2vec.model")
print(model['中国'])

使用模型时,直接以词作为 key 取出对应的向量,就可用以计算了,进一步地把多个词的向量进行组合(按维度求平均)就能得到句子的表征。

相较于 word2vec 这种静态的词向量,通过 BERT 等更复杂的预训练语言模型可以获得更好的语义表征,尽管我们自己训练一个 BERT 模型的代价很大,但是用公开的通用模型也还是可以有很大优化的,HuggingFace 提供了一整套的 transformers 生态,除了各类 BERT 的 API 还发布了大量已训练好的模型:

1
2
3
4
5
6
7
8
9
10
11
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base')

batch_sentences = ["我是一句话", "我是另一句话", "我是最后一句话"]
batch = tokenizer(batch_sentences, padding=True, return_tensors="pt")


from transformers import BertModel

model = BertModel.from_pretrained("bert-base")
bert_output = model(input_ids=batch['input_ids'], token_type_ids=batch['token_type_ids'], attention_mask=batch['attention_mask']

我们最好预先从 HuggingFace 官网下载好所需的模型与配置(包括 vocab.txt),首先初始化分词器,把词典所在目录作为参数传入;分词处理后返回包含“input_ids”、“token_type_ids”与“attention_mask”三组特征的批量数据,它们都是 BERT 网络需要的输入,待初始化模型后传入。模型的预测结果是两个元素的 tuple,其第一个元素是 last_hidden_state,即输出序列每个 token 的语义向量,其形状为 (batch_size, seq_length, hidden_size);第二个元素是 pooler_output,即 [CLS] 符号对应的语义向量,经过了全连接层和 tanh 激活,该向量可以认为是句子的表征,可直接用于下游的分类等任务。

关键词句

提取每一篇文档各自的关键词,这里介绍两种常用的算法,一个是前面提到的 TF-IDF 算法,另一个是 TextRank 算法。显然,用前面特征选择是介绍的方法可以得到文档的关键词:

1
2
3
4
5
# tf_idf = transformer.fit_transform(cv_fit)
word = cv_fit.get_feature_names()
weight = tf_idf.toarray()
for i in range(len(weight))
print(list(zip(word, weight[i]))) # 每篇文章中各个词的tf-idf权重

上述的方法显然是离线计算语料集合中每篇文档的关键词,有时我们需要在线计算单篇文档的关键词,则需要知道全局的 IDF 信息,可以事先统计全局语料的 IDF 值,用全局的 IDF 值排序得到的结果可以看做是全局关键词。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import math
from collections import defaultdict

doc_num = 0
doc_freq = defaultdict(int)
# 语料一行为一篇文档,且用空格分隔单词
with open('./corpus.txt', encoding='utf-8') as fin:
for sline in fin:
doc_num += 1
for word in set(sline.split(' ')):
doc_freq[word] += 1
idf = {}
for word in doc_freq:
idf[word] = math.log(doc_num/(doc_freq[word]+1.0))

结巴分词工具包里也提供了 TF-IDF 算法的接口:

1
2
3
4
5
from jieba import analyse

text = "关键词抽取就是从文本里面把跟这篇文档意义最相关的一些词抽取出来"
tfidf_keywords = analyse.extract_tags(text, topK=3, withWeight=True)
print(tfidf_keywords) # [('抽取', 1.5259575872683333), ('文档', 0.8323878872125), ('文本', 0.7454042161975)]

该接口只需要输入原始的文本,会自动分词并统计,这里默认使用结巴内置的 IDF 数据进行计算,我们也可以事先指定领域自己的 IDF 全局统计值 analyse.set_idf_path('./idf.txt')

TextRank 算法的接口也是类似的:

1
2
textrank_keywords = analyse.textrank(text, topK=3, withWeight=True)
print(textrank_keywords) # [('抽取', 1.0), ('相关', 0.6634924194712594), ('是从', 0.5870858571685045)]

进一步地延伸 TextRank 算法,如果我们提取出了一篇文档的关键句子,其实就可以做一个简单的文本摘要了:

1
2
3
4
5
6
7
8
9
10
import networkx as nx
# 首先构建两两句子相似度的矩阵
sentence_similarity_martix = build_similarity_matrix(sentences)

sentence_similarity_graph = nx.from_numpy_array(sentence_similarity_martix)
scores = nx.pagerank(sentence_similarity_graph)
ranked_sentence = sorted(((scores[i],s) for i,s in enumerate(sentences)), reverse=True)

for i in range(topN):
summarize_text.append(" ".join(ranked_sentence[i][1]))

首先,按照前文的方法学习句子的向量表征,构建两两句子的相似度矩阵,进而使用 networkx 工具包的 pagerank 接口计算关键句,使用权重最高的 topN 个句子组成摘要。

文本分类

行文至此,我们已经介绍了多种文本向量化的方法,包括统计词袋 TF-IDF、主题模型 LDA 的输出、基于 word2vec 或者 BERT。所有这些方法得到的文本表征输入到一个分类模型(除朴素贝叶斯之外的向量模型)中就可以实现文本分类任务:

1
2
3
4
5
6
7
8
from sklearn import svm

def train_clf(train_data, train_labels):
clf = svm.SVC(C=10.0, cache_size=200, class_weight=None, coef0=0.0, decision_function_shape=None, degree=3,
gamma='auto', kernel='rbf', max_iter=-1, probability=False, random_state=None, shrinking=True,
tol=0.001, verbose=False)
clf.fit(train_data, train_labels)
return clf

这里我们介绍 SVM 分类器的使用方法,参数 C 是惩罚系数,用来控制对损失函数的惩罚;class_weight 用来处理不平衡样本时的加权因子;kernel 是核函数,默认是 “rbf”核,还有线性核、多项式核等选项;degree 当核函数是多项式核函数的时候,用来控制函数的最高次数;max_iter:最大迭代次数,默认值是 -1,即没有限制;probability 是否使用概率估计,当需要输出分类概率时应设置为 True。分类器接收的数据是 numpy 数组定义的特征(二维)与标签(一维)。

有监督的学习会把样本划分为训练集与测试集,sklearn 为我们提供了现成的接口,test_size 指定用作测试集的样本比例:

1
2
3
4
5
6
7
8
9
10
11
12
from sklearn.model_selection import train_test_split

train_X,test_X,train_Y,test_Y = train_test_split(corpus, labels, test_size=0.33, random_state=1)


from sklearn.feature_extraction.text import TfidfVectorizer

def vectorize(train_X, test_X):
v = TfidfVectorizer()
train_data = v.fit_transform(train_X)
test_data = v.transform(test_X)
return train_data, test_data

在对训练集与测试集进行 TF-IDF 向量化的时候需要注意,我们是使用训练集来学习(或叫做提取)特征的,所以要用 .fit_trainsform() 函数;在测试集上使用已经学习到的特征去转换,则要用 .transform() 函数。

1
2
3
4
5
6
7
from sklearn import metrics

def evaluate(actual, pred):
m_precision = metrics.precision_score(actual, pred, average='macro')
m_recall = metrics.recall_score(actual, pred, average='macro')
print('precision:{0:.3f}'.format(m_precision))
print('recall:{0:0.3f}'.format(m_recall))

训练好的模型在测试集上进行 .predict(test_data) 预测,将预测结果与真实标签进行比对计算我们关系的指标,这些指标的计算 sklearn 也为我们封装好了。

序列标注

序列标注任务可以看做是对序列中的每个 token 都进行分类标签的预测,我们介绍使用 CRF 工具( pip install python-crfsuite)对这类任务进行建模的方法。序列数据中每个 token 的标签显然就是样本数据的 Y,而预测 Y 的特征一般采用的是当前 token 的前后 token 即上下文来构造。处理中文我们可以用字作为 token,遗憾的是该工具不支持 unicode 字符,不能直接用中文汉字组合特征,我们可以保存一份汉字对应 ID,用 ID 的组合作为特征来解决兼容性的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import pycrfsuite

def get_feature_words_id(token):
# 增加句首、句尾两个特殊token,英文符号可以直接返回
if token == '<BOS>' or token == '<EOS>':
return token
# 中文汉字则使用 ID 组合出特征
if token not in feature_words:
words_id = len(feature_words) + 1
feature_words[token] = 'W{}'.format(words_id)
return feature_words[token]

def gen_char_feature(token_lst, i):
# 特征设置为当前词与前后各一个词组
features = {
"-1:char": get_feature_words_id(token_lst[i-1]),
"0:char": get_feature_words_id(token_lst[i]),
"+1:char": get_feature_words_id(token_lst[i+1])
}
return features

def gen_char_labels(token_lst, entity_lst):
# TODO: 逐个生成text中每个字符的词位标识
labels = ['B_', 'I_', 'O']
return labels

一般地,以命名实体实例为例,对于 token 的标签,常使用 B_+标签名 的标识来表示一个由多字组成的实体的“首个字”, 以 I_+标签名 来表示实体“非首字”部分, 以 O 来表示非实体的部分序列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def gen_features_and_labels(corpus_path):
X = []
Y = []
fin = open(corpus_path, encoding='utf-8')
for sline in fin:
# 前后各补一个<BOS>和<EOS>
token_lst = ['<BOS>']
for char in sline.strip():
token_lst.append(char)
token_lst.append('<EOS>')

features = []
for i in range(1, len(token_lst)-1):
features.append(gen_char_feature(token_lst, i))
X.append(features)
Y.append(gen_char_labels(token_lst, entity_lst))

return X, Y

def train_crf(X, Y, model_path):
trainer = pycrfsuite.Trainer(verbose=True)
for x,y in zip(X,Y):
trainer.append(x, y)
trainer.set_params({
'c1': 1.0,
'c2': 1e-3,
'max_iterations': 10000,
'feature.possible_transitions': True
})
trainer.train(model_path)

训练时需要确保每个 x 和 y 的样本能够正确对应上:y 是当前 token 的标签,x 则是当前 token 上下文的组合特征,这是最容易出错的地方。

1
2
3
4
5
6
def test_crf(model_file, text):
tagger = pycrfsuite.Tagger()
tagger.open(model_file)
# 将预测的文本按字符生成特征,使用模型进行逐个字符的词位预测
feature_lst = [gen_char_feature(token_lst, i) for i in range(len(text))]
tag_lst = tagger.tag(feature_lst)

使用模型时,也要按照训练时构造的特征模板组合 token 构建特征,模型会输出序列中每个 token 的标签,根据 B/I/O 的前缀组合单个字符,就能实现诸如命名实体识别等任务。

信息抽取

信息抽取简单来说就是识别文本中的部分字符串并给出它们的标签,上文的序列标注显然可以用来实现该任务,只要我们有足够的标注数据训练一个 CRF 模型。但是,在一般的工程实践中我们都是没有标注数据的,那么此时的信息抽取可以使用封闭词典匹配、正则表达式匹配等方法来实现。这里我们着重介绍一种词典的最大匹配方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
class Node(object):
def __init__(self, name):
self.name = name
self.children = {}
self.is_word = False
self.tag = ""

class TriedTree(object):
def __init__(self):
self.root = Node("root")

def insert(self, word, tag):
if word == "":
return
word = list(word)

def do_insert(i, node):
if i == len(word):
node.is_word = True
node.tag = tag
return
sub_node = node.children.get(word[i])
if sub_node is None:
sub_node = Node(word[i])
node.children[word[i]] = sub_node
do_insert(i + 1, sub_node)

index = 0
first_node = self.root.children.get(word[index])
if first_node is None:
first_node = Node(word[index])
self.root.children[word[index]] = first_node
do_insert(index + 1, first_node)

def segment_word(self, sentence):
index = 0
result = []

if sentence == "":
return result
sentence = list(sentence)

def deep_first_search(i, node):
if i == len(sentence) and node.is_word:
return i, True, node.tag
if i == len(sentence) and not node.is_word:
return i, False, ""
sub_node = node.children.get(sentence[i])

if sub_node is None and node.is_word:
return i, True, node.tag
if sub_node is None and not node.is_word:
return i, False, ""

return deep_first_search(i + 1, sub_node)

while index < len(sentence):

first_node = self.root.children.get(sentence[index])
begin = index
index += 1
if first_node is None:
continue

end, success, tag = deep_first_search(index, first_node)
if not success:
continue

index = end
result.append({"word": "".join(sentence[begin:end]), "tag": tag, "begin": begin, "end": end})

return result

匹配算法的实现需要借助一种名叫 Trie树 的数据结构,上面的代码通过 insert() 方法实现对 Trie树的构建,我们的封闭词典一般包含用作匹配的词或词组,以及它们所对应的标签;segment_word() 方法实现了基于封闭词典的最大匹配,返回的对象中包含匹配结果的字符串、始末位置与标签。