【NLP 17、NLP的底子——分词】

[复制链接]
发表于 2024-12-21 09:58:30 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
我始终相信,世间所有的安排都有它的道理;失之东隅,收之桑榆
                                                                                          —— 24.12.20
  一、中文分词的介绍

1.为什么讲分词?

① 分词是一个被长期研究的任务,通过了解分词算法的发展,可以看到NLP的研究历程
② 分词是NLP中一类题目的代表
③ 分词很常用,很多NLP任务创建在分词之上
2.中文分词的难点

歧义切分

① 南京市长江大桥     ② 欢迎新老师生前来就餐        ③ 无线电法国别研究        ④ 乒乓球拍卖完了
新词/专用名词/改造词等

① 九漏鱼      ② 活性位点、受体剪切位点      ③ 虽迟但到、十动然拒

二、中文分词的方法

1.正向最大匹配

分词步调:

① 收集一个词表
② 对于一个待分词的字符串,从前向后寻找最长的,在此表中出现的词,在词边界做切分
③ 从切分处重复步调②,直到字符串末尾


实现方式一

1.找出词表中最大词长度
2.从字符串开头开始选取最大词长度的窗口,检查窗口内的词是否在词表中
3.如果在词表中,在词边界处进行切分,之后移动到词边界处,重复步调2
4.如果不在词表中,窗口右边界回退一个字符,之后检查窗口词是否在词表中
  1. #分词方法:最大正向切分的第一种实现方式
  2. import re
  3. import time
  4. #加载词典
  5. def load_word_dict(path):
  6.     max_word_length = 0
  7.     word_dict = {}  #用set也是可以的。用list会很慢
  8.     with open(path, encoding="utf8") as f:
  9.         for line in f:
  10.             word = line.split()[0]
  11.             word_dict[word] = 0
  12.             # 记录词的最大长度
  13.             max_word_length = max(max_word_length, len(word))
  14.     return word_dict, max_word_length
  15. #先确定最大词长度
  16. #从长向短查找是否有匹配的词
  17. #找到后移动窗口
  18. def cut_method1(string, word_dict, max_len):
  19.     words = []
  20.     while string != '':
  21.         lens = min(max_len, len(string))
  22.         word = string[:lens]
  23.         while word not in word_dict:
  24.             if len(word) == 1:
  25.                 break
  26.             #     词右边位置回退一位
  27.             word = word[:len(word) - 1]
  28.         words.append(word)
  29.         string = string[len(word):]
  30.     return words
  31. #cut_method是切割函数
  32. #output_path是输出路径
  33. def main(cut_method, input_path, output_path):
  34.     word_dict, max_word_length = load_word_dict("dict.txt")
  35.     writer = open(output_path, "w", encoding="utf8")
  36.     start_time = time.time()
  37.     with open(input_path, encoding="utf8") as f:
  38.         for line in f:
  39.             words = cut_method(line.strip(), word_dict, max_word_length)
  40.             writer.write(" / ".join(words) + "\n")
  41.     writer.close()
  42.     print("耗时:", time.time() - start_time)
  43.     return
  44. string = "测试字符串"
  45. string1 = "王羲之草书《平安帖》共有九行"
  46. string2 = "你到很多有钱人家里去看"
  47. string3 = "金鹏期货北京海鹰路营业部总经理陈旭指出"
  48. string4 = "伴随着优雅的西洋乐"
  49. string5 = "非常的幸运"
  50. word_dict, max_len = load_word_dict("dict.txt")
  51. print(cut_method1(string, word_dict, max_len))
  52. print(cut_method1(string1, word_dict, max_len))
  53. print(cut_method1(string2, word_dict, max_len))
  54. print(cut_method1(string3, word_dict, max_len))
  55. print(cut_method1(string4, word_dict, max_len))
  56. print(cut_method1(string5, word_dict, max_len))
  57. main(cut_method1, "corpus.txt", "cut_method1_output.txt")
复制代码


实现方式二        利用前缀字典

1.从前向后进行查找
2.如果窗口内的词是一个词前缀则继承扩大窗口
3.如果窗口内的词不是一个词前缀,则记录已发现的词,并将窗口移动到词边界
4.相较于第一种方式,查找次数少很多,这就是其效率要大于第一种方式的原因,本质上是采取用更多的内存存储的前缀辞书,来减少查询的次数,本质上接纳空间换时间的思想
利用前缀辞书,对遍历到的每一个词都进行判断,判断是否是一个词或是词的前缀,记录为1 / 0,如果遍历到的既不是一个词也不是一个词的前缀,则在当前这个词之前停下来,进行分词


  1. #分词方法最大正向切分的第二种实现方式
  2. import re
  3. import time
  4. import json
  5. #加载词前缀词典
  6. #用0和1来区分是前缀还是真词
  7. #需要注意有的词的前缀也是真词,在记录时不要互相覆盖
  8. def load_prefix_word_dict(path):
  9.     prefix_dict = {}
  10.     with open(path, encoding="utf8") as f:
  11.         for line in f:
  12.             word = line.split()[0]
  13.             for i in range(1, len(word)):
  14.                 if word[:i] not in prefix_dict: #不能用前缀覆盖词
  15.                     prefix_dict[word[:i]] = 0  #前缀
  16.             prefix_dict[word] = 1  #词
  17.     return prefix_dict
  18. #输入字符串和字典,返回词的列表
  19. def cut_method2(string, prefix_dict):
  20.     if string == "":
  21.         return []
  22.     words = []  # 准备用于放入切好的词
  23.     start_index, end_index = 0, 1  #记录窗口的起始位置
  24.     window = string[start_index:end_index] #从第一个字开始
  25.     find_word = window  # 将第一个字先当做默认词
  26.     while start_index < len(string):
  27.         #窗口没有在词典里出现
  28.         if window not in prefix_dict or end_index > len(string):
  29.             words.append(find_word)  #记录找到的词
  30.             start_index += len(find_word)  #更新起点的位置
  31.             end_index = start_index + 1
  32.             window = string[start_index:end_index]  #从新的位置开始一个字一个字向后找
  33.             find_word = window
  34.         #窗口是一个词
  35.         elif prefix_dict[window] == 1:
  36.             find_word = window  #查找到了一个词,还要在看有没有比他更长的词
  37.             end_index += 1
  38.             window = string[start_index:end_index]
  39.         #窗口是一个前缀
  40.         elif prefix_dict[window] == 0:
  41.             end_index += 1
  42.             window = string[start_index:end_index]
  43.     #最后找到的window如果不在词典里,把单独的字加入切词结果
  44.     if prefix_dict.get(window) != 1:
  45.         words += list(window)
  46.     else:
  47.         words.append(window)
  48.     return words
  49. #cut_method是切割函数
  50. #output_path是输出路径
  51. def main(cut_method, input_path, output_path):
  52.     word_dict = load_prefix_word_dict("dict.txt")
  53.     writer = open(output_path, "w", encoding="utf8")
  54.     start_time = time.time()
  55.     with open(input_path, encoding="utf8") as f:
  56.         for line in f:
  57.             words = cut_method(line.strip(), word_dict)
  58.             writer.write(" / ".join(words) + "\n")
  59.     writer.close()
  60.     print("耗时:", time.time() - start_time)
  61.     return
  62. string = "测试字符串"
  63. string1 = "王羲之草书《平安帖》共有九行"
  64. string2 = "你到很多有钱人家里去看"
  65. string3 = "金鹏期货北京海鹰路营业部总经理陈旭指出"
  66. string4 = "伴随着优雅的西洋乐"
  67. string5 = "非常的幸运"
  68. prefix_dict = load_prefix_word_dict("dict.txt")
  69. print(cut_method2(string, prefix_dict))
  70. print(cut_method2(string1, prefix_dict))
  71. print(cut_method2(string2, prefix_dict))
  72. print(cut_method2(string3, prefix_dict))
  73. print(cut_method2(string4, prefix_dict))
  74. print(cut_method2(string5, prefix_dict))
  75. # print(json.dumps(prefix_dict, ensure_ascii=False, indent=2))
  76. main(cut_method2, "corpus.txt", "cut_method2_output.txt")
复制代码
​​​​​


3.反向最大匹配

从右向左进行,基于相同的词表切分出差别的结果
两者都依赖词表,都有多种实现方式

4.双向最大匹配

同时进行正向最大切分,和负向最大切分,之后比力两者结果,决定切分方式。
怎样比力?

1.单字词:词表中可以有单字,从分词的角度,我们也会把它称为一个词
2.非字典词:未在词表中出现过的词,一般都会被分成单字
3.词总量:差别切分方法得到的词数大概差别
分析:我们一般认为,把词语切的很碎,分词后词的总量越少,单字字典词的数目越少,分词的效果越好
例

我们在野生动物园玩
词表:我们、在野、生动、动物、野生动物园、野生、动物园、在、玩、园
正向最大匹配法: “我们 / 在野 / 生动 / 物 / 园 / 玩”
        辞书词3个,单字字典词为2,非辞书词为1。
逆向最大匹配法: “我们 / 在 / 野生动物园 / 玩”
        辞书词2个,单字字典词为2,非辞书词为0。

5.jieba分词

jieba全切分词表
盘算哪种切分方式总词频最高 词频事先根据分词后语料(语言质料)统计出来
依赖于一个全切分方式的词表,将基于词表的所有大概切分方式列出,盘算哪种切分方式的总词频较高,把每个词的词频通过一份大的语料(语言质料)事先统计在一个文件中,然后盘算哪种切分方式下的总词频最高

6.正向最大切分,负向最大切分,双向最大切分共同的缺点:

① 对词表极为依赖,如果没有词表,则无法进行;如果词表中缺少必要的词,结果也不会正确
② 切分过程中不会关注整个句子表达的意思,只会将句子当作一个个片段
③ 如果文本中出现一定的错别字,会造成一连串影响
④ 对于人名等的无法罗列实体词无法有效的处置惩罚

三、基于机器学习的中文分词

1.分词任务转化为机器学习任务

如果想要对一句话进行分词,我们必要对于每一个字,知道它是不是一个词的边界,用一个0 / 1 序列来表示某一文字是不是词边界
创建一个模子,通过这个模子对一句话中每一个字进行判断是否是词边界,并用0 / 1 表示,这种规律可以用神经网络学习
题目转化为:
对于句子中的每一个字,进行二分类判断,正类表示这句话中,它是词边界,负类表示它不是词边界
标注数据、练习模子,使模子可以完成上述判断,那么这个模子,可以称为一个分词模子
神经网络练习完身分词任务可以看作是一个序列标注题目,能够使分词任务离开对于词表的依赖

2.构建模子

embedding嵌入层 + rnn + 线性层 + 交叉熵损失函数

① 继承父类

调用父类nn.Module的初始化方法。
② 嵌入层

创建一个嵌入层,初始化nn.Embedding,将词汇表中的每个词映射为固定维度的向量。
③ RNN层

创建一个多层RNN层,初始化nn.RNN,用于处置惩罚序列数据。
④ 线性层

创建一个线性层,初始化nn.Linear,将RNN输出映射到二维概率分布。
⑤ 损失函数

定义交叉熵损失函数,初始化nn.CrossEntropyLoss,并设置忽略padding位置的标签。
ignore_index:共同padding使用,设置padding位置值的标签label。
  1. class TorchModel(nn.Module):
  2.     def __init__(self, input_dim, hidden_size, num_rnn_layers, vocab):
  3.         super(TorchModel, self).__init__()
  4.         # embedding + rnn + 线性层 + 交叉熵
  5.         self.embedding = nn.Embedding(len(vocab) + 1, input_dim, padding_idx=0) #shape=(vocab_size, dim)
  6.         self.rnn_layer = nn.RNN(input_size=input_dim,
  7.                             hidden_size=hidden_size,
  8.                             batch_first=True,
  9.                             # 设置rnn的层数,两层rnn
  10.                             num_layers=num_rnn_layers,
  11.                             )
  12.         # 线性层,把每个 hidden_size 映射到二维的概率分布
  13.         self.classify = nn.Linear(hidden_size, 2)  # w = hidden_size * 2
  14.         # ignore_index:配合padding使用,padding位置值的标签label是-100
  15.         self.loss_func = nn.CrossEntropyLoss(ignore_index=-100)
复制代码

3.前向传播


① 输入嵌入

x = self.embedding(x):将输入 x 通过嵌入层转换为嵌入向量
输入外形:(batch_size, sen_len)
输出外形:(batch_size, sen_len, input_dim)
② RNN层处置惩罚:

x, _ = self.rnn_layer(x):将嵌入后的输入通过RNN层处置惩罚
输出外形:(batch_size, sen_len, hidden_size)
③ 分类

y_pred = self.classify(x):将RNN层的输出通过分类层进行分类
输出外形:(batch_size, sen_len, 2),重塑为:(batch_size * sen_len, 2)
④ 损失盘算

if y is not None:如果提供了标签 y,则盘算交叉熵损失
盘算公式:self.loss_func(y_pred.reshape(-1, 2), y.view(-1)),返回损失值。
⑤ 返回预测结果:

else:如果没有提供标签 y,则直接返回预测结果 y_pred
view(): view 函数主要用于改变张量(Tensor)的维度,也就是对张量进行外形重塑(reshape),但不会改变张量中元素的存储次序和数目
  1.     def forward(self, x, y=None):
  2.         x = self.embedding(x)  #input shape: (batch_size, sen_len), output shape:(batch_size, sen_len, input_dim)
  3.         x, _ = self.rnn_layer(x)  #output shape:(batch_size, sen_len, hidden_size)  
  4.         y_pred = self.classify(x)   #output shape:(batch_size, sen_len, 2) -> y_pred.view(-1, 2) (batch_size*sen_len, 2)
  5.         if y is not None:
  6.             #cross entropy
  7.             #y_pred : n, class_num    [[1,2,3], [3,2,1]]
  8.             #y      : n               [0,       1      ]
  9.             #y:batch_size, sen_len  = 2 * 5
  10.             #[[0,0,1,0,1],[0,1,0, -100, -100]]  y
  11.             #[0,0,1,0,1,  0,1,0,-100.-100]    y.view(-1) shape= n = batch_size*sen_len
  12.          
  13.             return self.loss_func(y_pred.reshape(-1, 2), y.view(-1))
  14.         else:
  15.             return y_pred
复制代码

4.加载和处置惩罚数据集

① 初始化

__init__:初始化类的属性,包括词汇表 (vocab)、数据集路径 (corpus_path) 和最大序列长度 (max_length)。
调用 load 方法加载数据。
② 加载数据

load:从指定路径读取文本文件。
对每行文本进行处置惩罚:
        sentence_to_sequence:将句子转换为字符序列
        sequence_to_label:生成标签序列
        padding:对序列和标签进行填充或截断
        将处置惩罚后的序列和标签转换为 PyTorch 张量
        将处置惩罚后的数据存储在 self.data 列表中
为了减少练习时间,只加载前 10000 条数据。
③ 填充或截断序列         

padding():将序列和标签截断或补齐到固定长度 max_length。
序列不敷的部分用 0 填充,标签不敷的部分用 -100 填充。
④ 获取数据集长度

__len__:返回数据集的长度,即 self.data 列表的长度。
⑤ 获取数据项

__getitem__:根据索引返回数据集中的一个数据项。
  1. class Dataset:
  2.     def __init__(self, corpus_path, vocab, max_length):
  3.         self.vocab = vocab
  4.         self.corpus_path = corpus_path
  5.         self.max_length = max_length
  6.         self.load()
  7.     def load(self):
  8.         self.data = []
  9.         with open(self.corpus_path, encoding="utf8") as f:
  10.             for line in f:
  11.                 sequence = sentence_to_sequence(line, self.vocab)
  12.                 label = sequence_to_label(line)
  13.                 sequence, label = self.padding(sequence, label)
  14.                 sequence = torch.LongTensor(sequence)
  15.                 label = torch.LongTensor(label)
  16.                 self.data.append([sequence, label])
  17.                 #使用部分数据做展示,使用全部数据训练时间会相应变长
  18.                 if len(self.data) > 10000:
  19.                     break
  20.     #将文本截断或补齐到固定长度
  21.     def padding(self, sequence, label):
  22.         sequence = sequence[:self.max_length]
  23.         sequence += [0] * (self.max_length - len(sequence))
  24.         label = label[:self.max_length]
  25.         label += [-100] * (self.max_length - len(label))
  26.         return sequence, label
  27.     def __len__(self):
  28.         return len(self.data)
  29.     def __getitem__(self, item):
  30.         return self.data[item]
复制代码

5.数据集处置惩罚


将一个文本句子转换为字符串
① 遍历句子

遍历输入的句子中的每个字符。
② 设定每个字的索引值

对于每个字符,使用字典 vocab 查找对应的索引值。如果字符不在字典中,则使用 vocab['unk'] 作为默认值。
③ 返回结果

将所有查找结果组成一个列表并返回。
  1. #文本转化为数字序列,为embedding做准备
  2. def sentence_to_sequence(sentence, vocab):
  3.     sequence = [vocab.get(char, vocab['unk']) for char in sentence]
  4.     return sequence
复制代码

④ 设置分词结果标注


1.分词得到词语列表
使用 jieba.lcut 对句子进行分词,得到词语列表
2.初始化标签列表
初始化一个与句子长度相同的全零标签列表
3.更新标签列表
遍历词语列表,更新标签列表中每个词语最后一个字符的位置为1
4.返回标签列表
返回终极的标签列表。
  1. #基于jieba词表生成分级结果的标注
  2. def sequence_to_label(sentence):
  3.     words = jieba.lcut(sentence)
  4.     label = [0] * len(sentence)
  5.     pointer = 0
  6.     for word in words:
  7.         pointer += len(word)
  8.         label[pointer - 1] = 1
  9.     return label
复制代码

⑤ 加载字表


enumerate(): 是 Python 内置的一个函数,它的主要作用是在遍历可迭代对象(如列表、元组、字符串等)时,同时获取元素的索引和对应的值,通常用于必要知道元素在序列中的位置信息的环境。这可以让代码在迭代过程中更加简洁和方便,避免了手动去维护一个索引变量来记录位置。
strip():是字符串的一个方法,用于去除字符串开头和末端的空白字符。空白字符包括空格、制表符(\t)、换行符(\n)等。
  1. #加载字表
  2. def build_vocab(vocab_path):
  3.     vocab = {}
  4.     with open(vocab_path, "r", encoding="utf8") as f:
  5.         for index, line in enumerate(f):
  6.             char = line.strip()
  7.             vocab[char] = index + 1   #每个字对应一个序号
  8.     vocab['unk'] = len(vocab) + 1
  9.     return vocab
复制代码

⑥ 创建数据集


1.创建数据集对象
DataSet 类:通常作为一个抽象基类(在一些库中实现了根本功能框架,但常需继承扩展使用),用于对数据集进行封装和管理,将数据以及对应的标签(在有监督学习场景下)等信息以一种规范、便于操作的方式构造起来,方便后续传递给数据加载器(如 DataLoader),进而提供给模子进行练习、验证或测试等操作。
2.创建数据加载器
DataLoader类:是一个非常紧张的数据加载工具。它的主要作用是将数据集(通常是继承自DataSet类的自定义数据集)按照指定的方式(如批次大小、是否打乱数据等)加载数据,使得数据能够以符合的格式和次序高效地提供给模子进行练习、验证或测试。
3.返回数据加载器
返回搭建好的data_loader
  1. #建立数据集
  2. def build_dataset(corpus_path, vocab, max_length, batch_size):
  3.     dataset = Dataset(corpus_path, vocab, max_length) #diy __len__ __getitem__
  4.     # DataLoader:python专门用于读取数据的类,用指定的batch_size为一组对样本数据进行打乱
  5.     data_loader = DataLoader(dataset, shuffle=True, batch_size=batch_size) #torch
  6.     return data_loader
复制代码

6.模子练习


① 初始化参数

设置练习相关的超参数:练习轮数、每次练习的样本个数、每个字的维度、隐含层的维度、rnn的层数、样本最大长度、学习率、子表文件路径和语料文件路径 
② 创建字表和数据集

调用build_vocab函数生成字符到索引的映射
调用build_dataset函数构建数据加载器
③ 创建模子和优化器

实例化第2步中构建的模子
实例化Adam优化器
④ 练习模子

在指定轮数内进行练习,每轮按批次处置惩罚数据,盘算损失并更新模子参数
⑤ 保存模子

练习竣事后保存模子参数
  1. def main():
  2.     epoch_num = 5        #训练轮数
  3.     batch_size = 20       #每次训练样本个数
  4.     char_dim = 50         #每个字的维度
  5.     hidden_size = 100     #隐含层维度
  6.     num_rnn_layers = 1    #rnn层数
  7.     max_length = 20       #样本最大长度
  8.     learning_rate = 1e-3  #学习率
  9.     vocab_path = "chars.txt"  #字表文件路径
  10.     corpus_path = "../corpus.txt"  #语料文件路径
  11.     vocab = build_vocab(vocab_path)       #建立字表
  12.     data_loader = build_dataset(corpus_path, vocab, max_length, batch_size)  #建立数据集
  13.     model = TorchModel(char_dim, hidden_size, num_rnn_layers, vocab)   #建立模型
  14.     optim = torch.optim.Adam(model.parameters(), lr=learning_rate)     #建立优化器
  15.     #训练开始
  16.     for epoch in range(epoch_num):
  17.         model.train()
  18.         watch_loss = []
  19.         # 按照batch_size数量的数据为一组,按组进行训练
  20.         for x, y in data_loader:
  21.             optim.zero_grad()    #梯度归零
  22.             loss = model.forward(x, y)   #计算loss
  23.             loss.backward()      #计算梯度
  24.             optim.step()         #更新权重
  25.             watch_loss.append(loss.item())
  26.         print("=========\n第%d轮平均loss:%f" % (epoch + 1, np.mean(watch_loss)))
  27.     #保存模型
  28.     torch.save(model.state_dict(), "model.pth")
  29.     return
复制代码
  1. if __name__ == "__main__":
  2.     main()
复制代码


7.用模子进行预测


① 初始化设置

设置字符维度、隐含层维度和RNN层数
② 创建词汇表

调用 build_vocab 函数读取词汇表文件并生成字典
③ 加载模子

创建模子实例并加载练习好的权重
load_state_dict(): 是在深度学习框架(如 PyTorch)中常用的一个方法,它主要用于加载模子的参数状态字典(state dictionary),使得模子能够恢复到之前保存的某个状态(比方之前练习好的状态,或者练习过程中某个特定阶段的状态),便于继承练习、进行模子评估或者在现实应用中使用已练习好的模子进行推理(预测)等操作。
④ 逐条预测

model.eval():将模子设置为评估模式,禁用 dropout 等练习时的活动
with torch.no_grad():不盘算梯度,提高推理速度并减少内存消耗,然后对生成的测试数据进行预测
⑤ 输出预测结果

对每个输入字符串进行编码,通过模子预测,并根据预测结果切分字符串后输出
  1. #最终预测
  2. def predict(model_path, vocab_path, input_strings):
  3.     #配置保持和训练时一致
  4.     char_dim = 50  # 每个字的维度
  5.     hidden_size = 100  # 隐含层维度
  6.     num_rnn_layers = 1  # rnn层数
  7.     vocab = build_vocab(vocab_path)       #建立字表
  8.     model = TorchModel(char_dim, hidden_size, num_rnn_layers, vocab)   #建立模型
  9.     model.load_state_dict(torch.load(model_path, weights_only=True))   #加载训练好的模型权重
  10.     model.eval()
  11.     for input_string in input_strings:
  12.         #逐条预测
  13.         x = sentence_to_sequence(input_string, vocab)
  14.         with torch.no_grad():
  15.             result = model.forward(torch.LongTensor([x]))[0]
  16.             result = torch.argmax(result, dim=-1)  #预测出的01序列
  17.             #在预测为1的地方切分,将切分后文本打印出来
  18.             for index, p in enumerate(result):
  19.                 if p == 1:
  20.                     print(input_string[index], end=" ")
  21.                 else:
  22.                     print(input_string[index], end="")
  23.             print()
  24. if __name__ == "__main__":
  25.     input_strings = ["同时国内有望出台新汽车刺激方案",
  26.                      "沪胶后市有望延续强势",
  27.                      "经过两个交易日的强势调整后",
  28.                      "昨日上海天然橡胶期货价格再度大幅上扬",
  29.                      "妈妈,我想你了"]
  30.     predict("model.pth", "chars.txt", input_strings)
复制代码
 

回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表