相较于基础篇章,这一部分相较于基础篇减少了很多算法推导,多了很多代码实现。
1.英文词规范化
英文词规范化一般分为标准化缩写,大小写相互转化,动词目态转化等。
1.1 大小写折叠
大小写折叠( casefolding) 是将所有的英文大写字母转化成小写字母的过程。在搜索场景中, 用户往往喜欢使用小写字母形式,而在计算机中, 大写字母和小写字母并非同一字符,当遇到用户想要搜索一些人名、地名等带有大写字母的专有名词的情况下,若不将小写字母转换成大写, 可能难以匹配正确的搜索结果。示例代码:
# 待处理的句子
sentence = "Let's study Hands-on-NLP"
# 将句子中的所有字符转换为小写
lowercase_sentence = sentence.lower()
# 打印转换后的句子
print(lowercase_sentence)
结果:
D:\ana\envs\nlp\python.exe D:\pythoncode\nlp\main.py
let's study hands-on-nlp
进程已结束,退出代码为 0
1.2 词目还原
在诸如英文这样的语言中,很多单词都会根据不同的主语、语境、时态等情形修改为相应的形态,而这些单词本身表达的含义是接近甚至相同的, 例如英文中的 am、 is、are都可以还原成 be,英文名词 cat根据不同情形有 cat、 cats、 cat's、 cats'等多种形态。这些形态对文本的语义影响相对较小,但是大幅提高了词表的大小, 因而提高了自然语言处理模型的构建成本。因此在有些文本处理问题上,需要将所有的词进行词目还原( lemmatization), 即找出词的原型。人类在学习这些语言的过程中,可以通过词典查找词的原型; 类似地,计算机可以通过构建词典来进行词目还原:
# 构建词典
lemma_dict = {
'am': 'be',
'is': 'be',
'are': 'be',
'cats': 'cat',
"cats'": 'cat',
"cat's": 'cat',
'dogs': 'dog',
"dogs'": 'dog',
"dog's": 'dog',
'chasing': 'chase'
}
# 待处理的句子
sentence = "Two dogs are chasing three cats"
# 将句子分割成单词列表
words = sentence.split()
# 打印原始单词列表
print(f'词目还原前: {words}')
# 初始化词目还原后的单词列表
lemmatized_words = []
# 遍历单词列表,进行词目还原
for word in words:
if word in lemma_dict:
lemmatized_words.append(lemma_dict[word])
else:
lemmatized_words.append(word)
# 打印词目还原后的单词列表
print(f'词目还原后: {lemmatized_words}')
代码结果如下:
词目还原前: [' Two', ' dogs', ' are', ' chasing', ' three', ' cats']
词目还原后: [' Two', ' dog', ' be', ' chase', ' three', ' cat']
另外,也可以利用NLTK 自带的词典来进行词目还原:
import nltk
from nltk.tokenize import word_tokenize
from nltk.stem import WordNetLemmatizer
from nltk.corpus import wordnet
# 下载分词包和wordnet包
nltk.download('punkt', quiet=True)
nltk.download('wordnet', quiet=True)
# 创建词形还原器实例
lemmatizer = WordNetLemmatizer()
# 待处理的句子
sentence = "Two dogs are chasing three cats"
# 使用nltk的分词器对句子进行分词
words = word_tokenize(sentence)
# 打印原始单词列表
print(f'词目还原前: {words}')
# 词形还原后的单词列表
lemmatized_words = []
# 遍历单词列表,进行词形还原
for word in words:
# 词形还原时需要确定词性,这里暂时使用名词(NOUN)作为示例
lemmatized_words.append(lemmatizer.lemmatize(word, pos=wordnet.NOUN))
# 打印词形还原后的单词列表
print(f'词目还原后: {lemmatized_words}')
代码结果如下:
D:\ana\envs\nlp\python.exe D:\pythoncode\nlp\cihuanyuan2.py
[nltk_data] Error loading punkt: <urlopen error [Errno 11004]
[nltk_data] getaddrinfo failed>
[nltk_data] Error loading wordnet: <urlopen error [Errno 11004]
[nltk_data] getaddrinfo failed>
词目还原前: ['Two', 'dogs', 'are', 'chasing', 'three', 'cats']
词目还原后: ['Two', 'dog', 'are', 'chasing', 'three', 'cat']
进程已结束,退出代码为 0
更精确的词目还原基于语素分析( morphological parsing)。在语言学中, 语素( morpheme)是语言中最小的有意义或有语法功能的单位。以中文为例,“动”“手”和“学”这3个语素就组合成了“动手学”这个词。在英文中, 情况会有些不一样, 英文中的很多单词是由词干( stem)和词缀( affix) 组成的。词干是表达主要含义的语素, 而词缀一般和词干连接,表达了附加的含义。例如 unbelievable这个词,由“ un”(词缀, 表示否定)、“ believ”(表示 believe,词干,表示相信)和“ able”(词缀, 表示可能的) 组成,三者合起来的意思是“不可置信的”。想要准确地抽取出词的词根和词干,就需要使用语素分析。
1.3 词干还原
词干还原( stemming)是将词变成词干的过程。词干还原是一种简单快速的词目还原的方式, 通过将所有的词缀直接移除来获取词干。为了保持词干的完整性, 波特词干还原器提出了一套基于改写规则的方法来进行词干还原。例如:
· TIONAL -> TION (如(conditional->condition);
· IZATION->IZE (如(organization>organize);
· SSES ->SS(如(classes→class)。
读者如果对这部分有兴趣的话,可以查阅NLTK 中对词干还原相关的描述。
2. 聚类模型分享
2.1 无监督的朴素贝叶斯模型
朴素贝叶斯模型假设一个文档中的所有词都是在给定文档标签的条件下独立同分布地通过一个离散分布生成。朴素贝叶斯模型定义文档的概率如下:
其中, πₖ是第k个簇的混合系数, ψₖ是第k个簇的离散分布(记为 Multi)的参数,表示为,v为词表大小。为当前文档的长度,表示文档中的第j个词。
将朴素贝叶斯模型用于文本分类时,文档标签是包含在数据中的, 因此可以进行监督学习。但在聚类任务中, 文档标签(即文档所属的簇)是未知的。因此, 与高斯混合模型的学习类似,我们需要同时学习朴素贝叶斯模型的参数和文档标签。常见的学习目标依然是最大化所有文档的概率,优化方法同样是最大期望值算法, 即随机初始化所有参数, 然后交替运行E步骤和M步骤直到模型收敛。
1. E步骤
计算每个文档的标签分布,将文档基于该分布部分地分配给各个标签:
其中, 上标(t)用于标示迭代次数, 为第t次迭代中的模型参数。
2. M步骤
根据每个标签被分配到的数据点进行加权最大似然估计,更新该标签对应的参数。首先,更新离散分布中每个词l的概率:
其中, 1()在括号内等式成立时为1,否则为0。这个公式的分子计算的是加权分配到第k个标签的所有文档中出现词1的加权总次数,分母计算的是加权分配到第k个标签的所有文档的总词数。其次, 还需要更新每个标签的混合系数,更新公式与5.2节基于高斯混合模型的最大期望值算法的M步骤对应公式完全相同:
其中, m为文档x的总个数。
算法代码演示:
from scipy.special import logsumexp
# 无监督朴素贝叶斯
class UnsupervisedNaiveBayes:
def __init__(self, K, dim, max_iter=100):
self.K = K
self.dim = dim
self.max_iter = max_iter
# 初始化参数,pi为先验概率分布,P用于保存K个朴素贝叶斯模型的参数
self.pi = np.ones(K) / K
self.P = np.random.random((K, dim))
self.P /= self.P.sum(axis=1, keepdims=True)
# E步骤
def E_step(self, X):
# 根据朴素贝叶斯公式,计算每个数据点分配到每个簇的概率分布
for i, x in enumerate(X):
# 由于朴素贝叶斯使用了许多概率连乘,容易导致精度溢出,
# 因此使用对数概率
self.Y[i, :] = np.log(self.pi) + (np.log(self.P) *\
x).sum(axis=1)
# 使用对数概率、logsumexp和exp,等价于直接计算概率,
# 好处是数值更加稳定
self.Y[i, :] -= logsumexp(self.Y[i, :])
self.Y[i, :] = np.exp(self.Y[i, :])
# M步骤
def M_step(self, X):
# 根据估计的簇概率分布更新先验概率分布
self.pi = self.Y.sum(axis=0) / self.N
self.pi /= self.pi.sum()
# 更新每个朴素贝叶斯模型的参数
for i in range(self.K):
self.P[i] = (self.Y[:, i:i+1] * X).sum(axis=0) / \
(self.Y[:, i] * X.sum(axis=1)).sum()
# 防止除0
self.P += 1e-10
self.P /= self.P.sum(axis=1, keepdims=True)
# 计算对数似然,用于判断迭代终止
def log_likelihood(self, X):
ll = 0
for x in X:
# 使用对数概率和logsumexp防止精度溢出
logp = []
for i in range(self.K):
logp.append(np.log(self.pi[i]) + (np.log(self.P[i]) *\
x).sum())
ll += logsumexp(logp)
return ll / len(X)
# 无监督朴素贝叶斯的迭代循环
def fit(self, X):
self.N = len(X)
self.Y = np.zeros((self.N, self.K))
ll = self.log_likelihood(X)
print(f'初始化log-likelihood = {ll:.4f}')
print('开始迭代')
for i in range(self.max_iter):
self.E_step(X)
self.M_step(X)
new_ll = self.log_likelihood(X)
print(f'第{i}步, log-likelihood = {new_ll:.4f}')
if new_ll - ll < 1e-4:
print('log-likelihood不再变化,退出程序')
break
else:
ll = new_ll
def transform(self, X):
assert hasattr(self, 'Y') and len(self.Y) == len(X)
return np.argmax(self.Y, axis=1)
def fit_transform(self, X):
self.fit(X)
return self.transform(X)
2.2基于高斯混合模型的最大期望值算法
这里首先介绍高斯混合模型,然后介绍用于无监督学习高斯混合模型的最大期望值算法。
2.2.1 高斯混合模型
相比于将每个数据点确定性地分类到一个簇中,给出每个数据点归属于每个簇的概率分布会更好地体现数据点和簇之间的关系。这里使用高斯混合模型来建模这个概率分布。顾名思义,高斯混合是指多个高斯分布(即正态分布) 函数的组合, 它的概率密度函数如下:
其中, πₖ是混合系数, 满足。也就是说, πₖ表示一个离散分布。N(x|μₖ,∑ₖ)是第k个高斯函数,其中μₖ为均值,维度与数据点x的维度一样,记作d, ∑为协方差矩阵,维度为d×d。N (x|μₖ,∑k)的具体概率密度函数为
其中, |∑|为协方差矩阵∑的行列式。
高斯混合也可以理解为一个生成式模型, 通过两个步骤生成数据点x。令变量y表示数据点x的标签,即x是从哪一个高斯函数采样得到的。第一步, 根据概率分布πₖ采样,令y=k; 第二步, 根据高斯分布. N(x|μₖ,∑ₖ)生成数据点。不难发现, 通过这个过程生成数据点x的概率密度函数即是前面定义的高斯混合。
在高斯混合用于聚类时, 每个高斯函数对应一个簇, 而每个数据点的标签则表示该数据点属于哪一个簇。在文本聚类这样的无监 贝叶斯网络督任务中,我们既不知道高斯混合的参数(即混合系数πₖ、均值μₖ和协方差∑ₖ),也不知道每个数据点的标签,因此需要同时学习这两者。常见的学习目标为最大化所有数据点的边际概率:
其中, N是数据集大小。那么如何优化这个目标函数呢? 最常见的方法是最大期望值算法。
2.2.1 最大期望值算法
最大期望值算法首先随机初始化高斯混合模型的所有参数,然后交替运行E步骤和M步骤,直到模型收敛(常见判断标准是边际概率不再显著增加)。
1. E步骤
E步骤将每个数据点按照一定的权重部分地分配给不同的高斯函数。我们将这些权重定义为该数据点被分配到各个高斯函数的概率, 即该数据点的标签的概率分布:
其中, 上标(t)用于标示第t次迭代时模型的参数,θ⁽ᵗ⁾为模型参数(即所有的、 和)。
2. M步骤
M步骤需要为每个高斯函数计算新的参数, 基本思想是根据每个高斯函数被分配到的数据点进行最大似然估计。由于每个数据点都是按一定的权重分配给每个高斯函数, 因此这里的最大似然估计也是加权的。这种加权最大似然估计存在以下闭式解:
上述公式将分配到第k个高斯函数的数据点进行加权平均。
上述公式将分配到第k个高斯函数的数据点计算加权的协方差矩阵。
其中, m是数据点的总个数。上述公式计算分配到第k个高斯函数的数据点个数占所有数据点个数的比例。由于每个数据点是按权重部分地分配给每个高斯函数的,因此数据点个数的计算方式是对权重求和。
通过E步骤和M步骤,我们就拥有了一个完整的最大期望值算法的过程。最大期望值算法具体细节不再展开,有兴趣的读者可以参考相关的机器学习教材。
2.2.3 最大期望值算法与k均值聚类算法的关联
如果为最大期望值算法假设两个限定条件:
(1)所有高斯模型都为球状(即协方差矩阵等比于单位矩阵),且权重和协方差均相同,只有均值是可变化的参数;
(2)E步骤中计算的标签概率分布均为点估计,也就是说强制将每个数据点分配给单个高斯模型, 这等价于假设所有的方差均无限接近于0。
我们就会发现, 最大期望值算法等价于k均值聚类算法。
接下来演示如何使用高斯混合模型来进行聚类。注意,高斯混合模型会计算每个数据点归属于各个族的概率分布,这里将概率最大的族作为聚类输出。
from scipy.stats import multivariate_normal as gaussian
from tqdm import tqdm
# 高斯混合模型
class GMM:
def __init__(self, K, dim, max_iter=100):
# K为聚类数目,dim为向量维度,max_iter为最大迭代次数
self.K = K
self.dim = dim
self.max_iter = max_iter
# 初始化,pi = 1/K为先验概率,miu ~[-1,1]为高斯分布的均值,
# sigma = eye为高斯分布的协方差矩阵
self.pi = np.ones(K) / K
self.miu = np.random.rand(K, dim) * 2 - 1
self.sigma = np.zeros((K, dim, dim))
for i in range(K):
self.sigma[i] = np.eye(dim)
# GMM的E步骤
def E_step(self, X):
# 计算每个数据点被分到不同簇的密度
for i in range(self.K):
self.Y[:, i] = self.pi[i] * gaussian.pdf(X, \
mean=self.miu[i], cov=self.sigma[i])
# 对密度进行归一化,得到概率分布
self.Y /= self.Y.sum(axis=1, keepdims=True)
# GMM的M步骤
def M_step(self, X):
# 更新先验概率分布
Y_sum = self.Y.sum(axis=0)
self.pi = Y_sum / self.N
# 更新每个簇的均值
self.miu = np.matmul(self.Y.T, X) / Y_sum[:, None]
# 更新每个簇的协方差矩阵
for i in range(self.K):
# N * 1 * D
delta = np.expand_dims(X, axis=1) - self.miu[i]
# N * D * D
sigma = np.matmul(delta.transpose(0, 2, 1), delta)
# D * D
self.sigma[i] = np.matmul(sigma.transpose(1, 2, 0),\
self.Y[:, i]) / Y_sum[i]
# 计算对数似然,用于判断迭代终止
def log_likelihood(self, X):
ll = 0
for x in X:
p = 0
for i in range(self.K):
p += self.pi[i] * gaussian.pdf(x, mean=self.miu[i],\
cov=self.sigma[i])
ll += np.log(p)
return ll / self.N
# 运行GMM算法的E步骤、M步骤迭代循环
def fit(self, X):
self.N = len(X)
self.Y = np.zeros((self.N, self.K))
ll = self.log_likelihood(X)
print('开始迭代')
for i in range(self.max_iter):
self.E_step(X)
self.M_step(X)
new_ll = self.log_likelihood(X)
print(f'第{i}步, log-likelihood = {new_ll:.4f}')
if new_ll - ll < 1e-4:
print('log-likelihood不再变化,退出程序')
break
else:
ll = new_ll
# 根据学习到的参数将一个数据点分配到概率最大的簇
def transform(self, X):
assert hasattr(self, 'Y') and len(self.Y) == len(X)
return np.argmax(self.Y, axis=1)
def fit_transform(self, X):
self.fit(X)
return self.transform(X)