从 Token 说起的大语言模型

600 阅读20分钟

一、什么是 token

image.png

  • 说起 token 这个概念,可能在之前的自然语言处理研究中出现的比较多,真正飞入寻常百姓家的时候还是在大模型火了之后, token 计费机制的兴起,以及上下文长度的 token 计数法开始流传开来。

  • 作为一个非自然语言研究的普通人,不知道您是否有过这样一种好奇:token 到底是啥玩意?

  • 也许您听过有的人回答它就是字符一段内容的 token 数等于字节数之类云云。 但是事实真是这样吗?只能说对,但是对的不多~ 来看下 token 比较官方的解释:

    Def: token 在自然语言处理中扮演着重要的角色,它 可以 是最小文本单元、最基础数据单元或最小语义单位。

    哈哈,是的,最小的字符单元仅仅是一种可能性。也就是说 token 还可能出现是一个词,一段字符等等情况。那么我们又要如何定义它,或者说知道我输入的内容到底有多少 token 呢?别急,下面的代码可以给你答案:

''' code 0x01 '''

# 基于OpenAI tiktoken计算
import tiktoken

def count_tokens_with_tiktoken(input_string):
    encoding = tiktoken.get_encoding("cl100k_base")
    tokens = encoding.encode(input_string)
    return len(tokens)

input_str = "这是一个测试字符串,您可以修改这里换成您希望计算的。"
token_count = count_tokens_with_tiktoken(input_str)
print(f"输入字符串的 token 数量为:{token_count}")
''' code 0x02 '''


# 使用 ChatGLM 分词器进行分词,需提前下载好对应模型文件与配置文件,并且需要先创建一个叫tokenizer的文件夹
# 打开下载地址,把最下面四个文件下载就行了,模型权重本身不要管
# 下载地址:https://modelscope.cn/models/ZhipuAI/chatglm3-6b/files  
import os
import platform
from transformers import AutoTokenizer

TOKENIZER_PATH = 'tokenizer'

# 如果发生报错请确保一下本机transformer版本,可以参考修改成4.40.2
# 命令行指令:pip install transformers==4.40.2 -i https://pypi.tuna.tsinghua.edu.cn/simple
tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_PATH, trust_remote_code=True)

def count_tokens(input_string):
    tokens = tokenizer.encode(input_string)
    return len(tokens)

input_str = "这是一个测试字符串,您可以修改这里换成您希望计算的。"
token_count = count_tokens(input_str)
print(f"输入字符串的 token 数量为:{token_count}")

执行结果:

image.png
code 0x01 输出结果

image.png
code 0x02 输出结果

不出意外的话,上面两个代码都可以完成我们希望的功能--统计token数。但是,他们还不够优秀,我们希望在统计完之后输出token到底是一些什么东西,以及到底我们的一句话被分割成了什么东西。实现上比较简单,在统计函数return之前追加下面代码
print(tokens)

新的执行结果:

image.png
code 0x01 新输出结果

image.png
code 0x02 新输出结果

比较amazing的是,两份代码运行结果输出的都是一个数字列表,我们输入进去的汉字不见了。这些数字代表的是什么呢?这其实就是咱们一直在找的 token,没想到居然就是这么一个鬼东西,问题是咱们的内容去哪了呢?别急,咱们再加一个新的功能,解码一下就出来了。还是在获取长度功能函数中实现。
tokens_ele = [tokenizer.decode([ele]) for ele in tokens]
print(tokens_ele)

image.png 本质上这些数字相当于一个词表中的索引号,相关的具体内容就是从一个巨大的表中拿到的对应的字符
完成上面全套流程在大模型中称作完成了分词,对应词表与分词编码解码算法又叫分词器。
大语言模型中,分词器(又叫 tokenizer )的作用至关重要,它将文本转化为一个个 token,便于模型进行处理和分析。这玩意相应的算法之类的最开始是英文版的,对于那个简单,最后分词出来无非就是字母、词缀、单词之类的,而且,人家存在一个先天分割符号--空格,整个英文语言体系中都不存在多少符号,空格一删掉,英文直接废弃。但是换到中文就有问题了,咱们如果也一个字一个字去看,那事儿大了(也有相应的研究),相应的研究追忆起来真就坎坷。

中文分词器的探索历程

一、早期探索阶段
在自然语言处理发展的早期,中文分词主要依靠简单的规则和字典匹配方法。通过构建中文词典,对文本进行正向最大匹配、逆向最大匹配等方式进行分词。例如,从文本的起始位置开始,依次查找词典中最长的匹配词作为一个分词结果。这种方法的优点是简单直观,对于常见词汇的分词效果较好。但对于新词、未登录词的识别能力有限,且依赖于词典的质量和规模。

# 词典大法大概实现如下
def forward_max_matching(text, dictionary):
    words = []
    max_word_length = max([len(word) for word in dictionary])
    while text:
        word = text[:max_word_length]
        while len(word) > 0 and word not in dictionary:
            word = word[:-1]
        words.append(word)
        text = text[len(word):]
    return words
    
# 我们实现分词的效果多好取决于构建出来的字典(也可以叫词表有多好,后面会给出构建方法实现)
dictionary = ["我", "爱", "你", "喜欢", "是", "一个", "伟大",\
              "的", "国家", "希望", "学习", "中文", "分词"]
text = "我喜欢你"
result = forward_max_matching(text, dictionary)
print(result)

二、机器学习方法引入阶段
相信大家会发现,纯手工构建词表字典虽然简单易懂,但是,它架不住超出范围的词,而且真正力大砖飞的力很难,除非去抓某个语言学家坐着给你构建一个几十万长度的词表,否则根本没有可用性,也不好扩展。
好在,机器学习技术发展了,纯小词表大师造时代一去不复返了。中文分词开始引入统计机器学习方法,比较典型代表之作(具体原理与实现并不是本文重点,有兴趣了解的读者可以自行搜索相应文档):
1. 隐马尔可夫模型(HMM):将中文分词问题建模为一个序列标注问题,每个字符被标注为词首、词中、词尾或单字词等状态。通过 HMM 对文本序列进行状态预测,从而实现分词。HMM 优势在于能够利用统计信息,对未登录词有一定的识别能力。

# 由于讲本部分就必须提到HMM的原理,包括公式这些,甚至还有上下文语料训练。流程比较繁琐
# 贴一个我觉得写得不错的博客供大家参考
[NLP实战:基于HMM实现中文分词【含完整代码】 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/658098049)

2. 条件随机场(CRF):相比 HMM,CRF 能够更好地利用上下文信息进行序列标注。它可以考虑更多的特征,如字符特征、词性特征、词频特征等,从而提高分词的准确性。

# 同上哈,篇幅原因并不展开赘述,本来就是一个简单的短文,博您茶余饭后一乐
# 工作和学习本身就很无聊了(bushi),就不整枯燥的推导和公式了
[GitHub - hooser/ChineseWord_seg: 使用条件随机场模型(CRF)进行中文分词](https://github.com/hooser/ChineseWord_seg)

以上方法都是在拥有一定文本数据前提下开始进行训练的,本质上这一步就是统计学家抢语言学家的活,然后通过机器表达出来的过程。但是只要语料给的好,得出的分词方法具备相当的可用性,只不过,在信息爆炸的时代,新词理解并不总是那么的准确,且对应的方法内部简单的应用统计方法进行数据压缩,得出的结论也不是那么的好,能用但不好用时代~

三、深度学习兴起阶段
近年深度学习技术在自然语言处理中取得了巨大成功,也极大地推动了中文分词的发展。基于神经网络的分词方法:使用卷积神经网络(CNN)、循环神经网络(RNN)及其变体(如长短期记忆网络 LSTM、门控循环单元 GRU)等构建分词模型。这些模型可以自动学习文本的特征表示,无需人工设计复杂的特征,相应的学习在统计学习模型基础上更进一步的抽取到了特征。
还是举个栗子:双向 LSTM + CRF 模型成为一种常见的中文分词架构。

# 偷懒,训练与具体原理看官移步下面链接~
[基于双向BiLstm神经网络的中文分词详解及源码](https://www.cnblogs.com/vipyoumay/p/8608754.html)

通过引入双向 LSTM 等等模型,可以更好地捕捉上下文信息,CRF 层则可以考虑标签之间的约束关系,提高分词的准确性。甚至后面也出现了基于 Transformer 架构的模型的分词(当然很快被预训练大模型打飞了),随着 BERT、GPT 等预训练语言模型出现,在中文分词任务中也展现出了强大的性能。这些模型本质上通过了大规模的无监督学习,能够学习到丰富的语言知识和语义表示。可以在预训练的基础上,通过微调等方式应用于中文分词任务。。

# 各位看官您自行看哈,预训练大模型本质也是模型,除了生成之外分类也是一个常见的操作
[Bert中文分词](https://www.jianshu.com/p/be0a951445f4)

二、分词器的使用方法

image.png

前面其实已经叙述过了关于分词器的使用,相信大家也基本掌握与理解了这玩意是个啥。那么,就让咱们继续趁热打铁,把一些常用的分词工具顺带讲一下,很快。毕竟咱们的重点还是在分词器自己实现上。
  1. jieba(结巴分词)
    • 特色
      • 免费使用,是 Python 中常用的中文分词组件。
      • 支持多种分词模式,如精确模式、全模式、搜索引擎模式等。
      • 具有较高的分词准确率和效率。

示例代码

import jieba

# 精确模式分词
text = "我喜欢自然语言处理"
words = jieba.cut(text, cut_all=False)  # cut_all=False为精确模式
print(list(words))

# 全模式分词
words_all = jieba.cut(text, cut_all=True)
print(list(words_all))

# 搜索引擎模式分词
words_search = jieba.cut_for_search(text)
print(list(words_search))
  1. HanLP(汉语言处理包)
    • 特色
      • 由一系列模型与算法组成的强大 NLP 工具包,完全开源。
      • 功能完善,涵盖了词性标注、命名实体识别、依存句法分析等多种自然语言处理任务。
      • 性能高效,具备快速处理大量文本的能力。
      • 架构清晰,易于使用和扩展。
      • 语料时新,保证了处理结果的准确性和实用性。
      • 可自定义,用户可以根据自己的需求进行定制化配置。

示例代码

from pyhanlp import HanLP

text = "我爱北京天安门"
# 进行词性标注和命名实体识别
result = HanLP.parse(text)
print(result)

# 仅进行分词
words = HanLP.segment(text)
for word in words:
    print(word.word)
  1. SnowNLP(中文的类库)
    • 特色
      • 简单易用,提供了一些基本的中文文本处理功能。
      • 可以进行情感分析、文本分类等任务的初步探索。

示例代码

from snownlp import SnowNLP

text = "这部电影真好看"
s = SnowNLP(text)
# 情感分析,返回一个介于0和1之间的值,表示积极程度
print(s.sentiments)

# 进行分词
print(s.words)
  1. FoolNLTK(中文处理工具包)
    • 特色
      • 专注于中文自然语言处理,提供了较为全面的中文文本处理功能。
      • 具有较好的易用性和准确性。

示例代码

import foolnltk

text = "今天天气真好"
# 进行分词
words = foolnltk.cut(text)
print(words)

# 进行词性标注
tags = foolnltk.pos_tag(words)
print(tags)
  1. Jiagu(甲骨 NLP)
    • 特色
      • 提供了多种自然语言处理功能,包括分词、词性标注、命名实体识别等。
      • 具有较高的准确性和效率。

示例代码

import jiagu

text = "习近平主席强调了科技创新的重要性"
# 进行分词
words = jiagu.segment(text)
print(words)

# 进行词性标注
tags = jiagu.tag(words)
print(tags)

# 进行命名实体识别
entities = jiagu.ner(words)
print(entities)
  1. pyltp(哈工大语言云)
    • 特色
      • 基于哈工大的语言技术平台,具有较高的技术水平和准确性。
      • 提供了多种自然语言处理功能,如分词、词性标注、命名实体识别、依存句法分析等。
      • 商用需要付费,但对于学术研究可能有一定的免费使用权限。

示例代码

import pyltp

# 加载分词模型
segmentor = pyltp.Segmentor()
segmentor.load('/path/to/your/ltp_model/cws.model')

text = "哈尔滨工业大学是一所著名的高校"
words = segmentor.segment(text)
print(list(words))

# 释放模型资源
segmentor.release()
  1. THULAC(清华中文词法分析工具包)
    • 特色
      • 由清华大学开发,具有较高的学术水平和准确性。
      • 提供了分词和词性标注功能。
      • 商用需要付费,但可能有一些试用版本可供使用。

示例代码

import thulac

thu = thulac.thulac()
text = "我在清华大学学习"
words_and_tags = thu.cut(text)
for word, tag in words_and_tags:
    print(word, tag)
  1. NLPIR(汉语分词系统)
    • 特色
      • 是一款较为成熟的汉语分词系统,具有较高的准确性和稳定性。
      • 提供了多种分词模式和功能,使用起来凑合。
      • BUT,比较坑,需要付费使用。

示例代码

from pynlpir import nlpir

nlpir.Init()
text = "中文信息处理是一个有趣的领域"
words = nlpir.Segment(text)
print(words)
nlpir.Exit()

三、简易分词器的实现

(一)基于 Python jieba 包实现简易分词器

实现一个分词器,最简单的就是直接拿着别人的工具做二次开发,直接套壳就OK~
因此,可以有以下实现:基于 Python 的 jieba 包实现简易分词器的示例代码:

import jieba
class Token:
    def __init__(self, text):
        self.text = text
        self.tokens = jieba.cut(text)
    def get_tokens(self):
        return list(self.tokens)

通过这个简单的 Token 类,我们可以方便地对输入的文本进行分词操作。例如:

tokenizer = Token("我来到北京清华大学")
print(tokenizer.get_tokens())

这将输出使用 jieba 分词后的结果,如 ['我', '来到', '北京', '清华大学']。

(二)jieba 分词局限性与 BPE 改进分词

这些库虽好,但是本质上还是不利于我们实现和理解原理,因此,咱们还是需要自行搓一个看看深浅~
有道是手中有枪心里不慌,咱们说干就干,先来个最简单的英格力士版本的分词。


补充一个小姿势:

  • 什么是BPE?
    字节对编码(Byte Pair Encoding,BPE)是一种用于自然语言处理的字符级别的文本压缩算法,也常被应用于分词等任务中。它的主要目标是通过学习字符或字符序列的组合模式,来创建一个适合特定语言或文本数据集的编码表。在自然语言处理中,BPE 通常用于处理词汇表外(Out-of-Vocabulary,OOV)的单词,通过将单词分解为更小的子词单元(subword units),从而提高模型对未知词汇的处理能力和泛化性能。
  • BPE 如何工作
    BPE 的原理基于迭代地合并最频繁出现的字符对。初始时,将文本中的每个字符视为一个独立的符号。然后,统计文本中相邻字符对的出现频率。在每次迭代中,选择出现频率最高的字符对,并将其合并为一个新的符号,更新编码表。这个过程不断重复,直到达到预设的迭代次数或满足其他停止条件。
    小栗子: 对于文本 “aaabdaaabac”,首先字符 “a”“b”“c”“d” 各自独立,假设 “aa” 出现频率最高,则将其合并为一个新符号,比如 “Z”,那么文本就变为 “ZabdZabac”。接着继续统计和合并,不断优化编码表。通过这种方式,BPE 可以学习到文本中的常见字符组合模式,形成一种紧凑且有效的编码方式,用于后续的语言处理任务,如分词时可以根据学习到的子词单元来对单词进行更合理的分割。

import collections
import re

# 从词汇表中提取令牌和对应的频率信息以及词汇与其令牌的映射
def extract_tokens_from_vocab(my_vocab):
    # 存储令牌的频率,默认值为 0
    token_freqs = collections.defaultdict(int)
    # 存储词汇与其令牌的映射
    vocab_token_mapping = {}
    # 遍历词汇表中的每个词汇及其频率
    for term, frequency in my_vocab.items():
        # 将词汇拆分为令牌
        term_tokens = term.split()
        # 累加令牌的频率
        for token in term_tokens:
            token_freqs[token] += frequency
        # 建立词汇与令牌的映射
        vocab_token_mapping[''.join(term_tokens)] = term_tokens
    return token_freqs, vocab_token_mapping

# 计算令牌的长度
def calculate_token_length(token_str):
    if token_str[-4:] == '</w>':
        # 如果令牌以 '</w>' 结尾,返回去除结尾后的长度加 1
        return len(token_str[:-4]) + 1
    else:
        # 否则直接返回令牌长度
        return len(token_str)

# 对给定的字符串进行令牌化处理
def tokenize_word_str(word_str, sorted_tokens_list, unknown_token='</u>'):
    if word_str == '':
        return []
    if sorted_tokens_list == []:
        return [unknown_token] * len(word_str)

    tokenized_result = []

    # 遍历已排序的令牌列表
    for index, token in enumerate(sorted_tokens_list):
        # 创建令牌的正则表达式模式
        token_pattern = re.escape(token.replace('.', '[.]'))
        # 在字符串中查找令牌的匹配位置
        matched_positions = [(m.start(0), m.end(0)) for m in re.finditer(token_pattern, word_str)]
        if len(matched_positions) == 0:
            continue
        substring_end_positions = [matched_position[0] for matched_position in matched_positions]
        substring_start_pos = 0

        # 处理每个匹配的子字符串
        for substring_end_pos in substring_end_positions:
            substring = word_str[substring_start_pos:substring_end_pos]
            # 递归地对子字符串进行令牌化处理
            tokenized_result += tokenize_word_str(substring, sorted_tokens_list[index + 1:], unknown_token)
            tokenized_result += [token]
            substring_start_pos = substring_end_pos + len(token)
        remaining_substring = word_str[substring_start_pos:]
        # 对剩余的子字符串进行令牌化处理
        tokenized_result += tokenize_word_str(remaining_substring, sorted_tokens_list[index + 1:], unknown_token)
        break
    else:
        # 如果没有找到匹配的令牌,返回未知令牌列表
        tokenized_result = [unknown_token] * len(word_str)

    return tokenized_result

# 对令牌按长度和频率进行排序
def sort_tokens_list(token_freq_dict):
    # 按照令牌长度和频率进行排序
    sorted_token_tuples = sorted(token_freq_dict.items(), key=lambda item: (calculate_token_length(item[0]), item[1]), reverse=True)
    # 提取排序后的令牌列表
    sorted_tokens_only = [token for (token, freq) in sorted_token_tuples]
    return sorted_tokens_only

# 示例用法
my_vocab = {'natural language': 3, 'processing': 4, 'lecture': 4}
token_freqs, vocab_token_mapping = extract_tokens_from_vocab(my_vocab)
sorted_tokens = sort_tokens_list(token_freqs)
print("Tokens =", sorted_tokens, "\n")

sentence_1 = 'I like natural language processing!'
sentence_2 = 'I like natural languaaage processing!'
sentence_collection = [sentence_1, sentence_2]

for sentence in sentence_collection:
    print('==========')
    print("Sentence =", sentence)
    for word in sentence.split():
        word = word + "</w>"
        print('Tokenizing word: {}...'.format(word))
        if word in vocab_token_mapping:
            print(vocab_token_mapping[word])
        else:
            print(tokenize_word_str(string=word, sorted_tokens=sorted_tokens, unknown_token='</u>'))

关于上面的代码主要实现了从给定的词汇表中提取令牌(tokens)、计算令牌长度、对字符串进行令牌化处理以及对令牌按长度和频率进行排序等功能。
对于主要函数功能解释

  • extract_tokens_from_vocab(my_vocab)

    • 这个函数接受一个词汇表作为输入,词汇表以字典形式呈现,键为词汇,值为该词汇的频率。
    • 函数内部使用了collections.defaultdict来创建一个默认值为整数 0 的字典token_freqs,用于存储令牌的频率。同时创建了一个字典vocab_token_mapping用于存储词汇与其令牌的映射关系。
    • 遍历输入的词汇表,对于每个词汇,将其拆分为令牌(通过split方法),然后累加每个令牌在token_freqs中的频率,并将词汇与其令牌的映射关系存储在vocab_token_mapping中。最后返回令牌频率字典和词汇令牌映射字典。
  • calculate_token_length(token_str)

    • 该函数用于计算给定令牌的长度。如果令牌以</w>结尾,则返回去除结尾后的长度加 1;否则直接返回令牌的长度。
    • tokenize_word_str(word_str, sorted_tokens_list, unknown_token='</u>')
    • 此函数对给定的字符串进行令牌化处理。它接受要处理的字符串、已排序的令牌列表和未知令牌的表示作为参数。
    • 如果输入的字符串为空或者已排序的令牌列表为空,则分别返回空列表或由未知令牌组成的与输入字符串长度相同的列表。
    • 函数通过遍历已排序的令牌列表,尝试在输入字符串中找到匹配的令牌。一旦找到匹配的令牌,就将字符串拆分为子字符串,并递归地对每个子字符串进行令牌化处理。最后将处理后的结果组合起来返回。如果在整个过程中都没有找到匹配的令牌,则返回由未知令牌组成的列表。
  • sort_tokens_list(token_freq_dict)

    • 这个函数接受一个令牌频率字典作为输入,按照令牌的长度和频率对令牌进行排序。
    • 首先使用sorted函数根据令牌长度和频率对令牌频率字典中的项进行排序,排序结果是一个由令牌和频率组成的元组列表。然后从这个列表中提取出令牌,组成一个新的已排序的令牌列表并返回。

示例用法: 在示例用法中,首先创建了一个示例词汇表my_vocab。然后调用extract_tokens_from_vocab函数从词汇表中提取令牌频率和词汇令牌映射信息。接着调用sort_tokens_list函数对令牌按长度和频率进行排序,得到已排序的令牌列表sorted_tokens
之后定义两个示例句子sentence_1sentence_2,并将它们放入一个列表sentence_collection中。对于每个句子,遍历其中的每个单词,在单词后添加"</w>"标记。如果单词在词汇令牌映射字典中存在,则打印对应的令牌;如果不存在,则调用tokenize_word_str函数对该单词进行令牌化处理,并打印结果。功能比较简单,大家可以自行实现下

(三)朴素 BPE 对于中文分词的缺陷

虽然咱们基于BPE的思想实现了一个简单的分词器,但是朴素 BPE 在中文分词方面存在诸多缺陷。其一,它通常仅停留在字一级的分词层面,难以捕捉到更丰富的结构信息。例如成语或固定短语这类具有特定整体语义的词汇,往往会被拆分成单个的字,像 “一马当先” 可能被分割为 “一”“马”“当”“先”,破坏了其原本的语义完整性。
此外,中文语言结构的复杂性给 BPE 带来了诸多挑战。一方面,中文存在多音字、同音字等情况,BPE 可能无法准确处理。比如 “银行” 和 “行走” 中的 “行” 是多音字,“辩” 和 “辨” 是同音字,BPE 在分词时难以根据上下文准确判断其正确读音和含义。另一方面,BPE 算法对训练语料和计算资源要求较高,这对于小型项目或资源有限的场景不太适用,因为这些情况下可能无法提供足够的语料和计算能力。而且,中文的语法和语义对分词结果影响很大,词语顺序和语法结构不同会导致语义差异,如 “我吃饭” 和 “饭吃我”,但 BPE 可能无法很好地考虑这些因素,从而导致分词结果不准确,在处理词序灵活的情况时也容易出现问题。
但是,关于咱们的目的而言,已经达到了,实现一个大模型所需要的token部分基本完成。
接下来,咱们会再次把目光放在大模型架构解读与实现、训练等工作上,一步一步的去完成这个工程。诸君一道~

enjoy it!