AI知识库

53AI知识库

学习大模型的前沿技术与行业应用场景


【LLM基础知识】LLMs-Tokenizer知识总结笔记v2.0
发布日期:2024-08-06 21:47:16 浏览次数: 1956



Tokenize粒度和Tokenizer

                                                                            

【1】Tokenize有三种粒度:word/char/subword

【1】Tokenize有三种粒度:word/char/subword

a.Tokenize的目标:Tokenize的目标是把文本text切分成子串token,每个子串token相对有完整的语义,便于学习embedding表达和后续模型的使用。
b.Tokenize的粒度:基于词[word]的切分,基于字符[char]的切分,基于子词级[subword]的切分。
以英文文本 Today is sunday为例,切分结果如下:
word: 按照词进行分词,可根据空格或标点进行切分。如: Today is sunday. 切分成[today, is, sunday, .]
char:按照单字符进行分词,就是以char为最小粒度。 如:Today is sunday. 切分成[t,o,d,a,y, ... ,s,u,n,d,a,y, .]
subword:按照词的subword进行分词,将word拆分为子串。如:Today is sunday. 切分成[to, day,is , s,un,day, .]
                                                                            

3.Tokenize的各粒度的优缺对比:

a.基于word的分词方式优点是保留单词的边界和意义,相比于char语义表达更加充分,缺点是会导致词表变大,稀有词学不好,易出现OOV问题,无法处理单词词缀关系。
b.基于char的分词方式优点是词表小,缺点是缺乏单词的语义信息,分词的结果较长,增加文本表征的成本。
c.基于subword的分词方式平衡以上两种方法优缺点,可以较好的平衡词表大小和语义表达能力。
                                                                            

4.基于subword的切分是目前主流切分方式。subword的切分包括: BPE(/BBPE), WordPiece 和 ULM三种分词模型。WordPiece是种特殊的BPE。

a.使用 BPE模型【主流】:GPT, GPT-2, RoBERTa, BART, DeBERTa, LLaMA, ChatGLM, Baichuan等。

b.使用WordPiece模型:BERT, DistilBERT, MobileBERT, Funnel Transformers, MPNET等。

c.使用ULM模型:AlBERT,T5,mBART,Big Bird,XLNet等。

Tonkenizer各种分词方法对比

                                                                            

5. Tokenizer对输入文本的分词处理流程包括:Normalization文本归一化,Pre-tokenization预分词,Model基于分词模型的切分,Postprocessor后处理。

a.Normalization:文本归一化阶段,进行常规清理例如删除多余换行、空格、转小写、以及删除重音符号等。

b.Pre-tokenization:预分词阶段,会把句子切分成更小的“词”单元。可以基于空格或者标点进行切分。

c.Model:基于分词模型的切分阶段,使用如BPE分词模型执行分词从而生成token序列。

d.Postprocessor:后处理阶段,针对具体的任务插入special token,以及生成attention mask等。

HuggingFace-Tokenizer对输入文本的分词处理流程

抱抱脸文档:

https://huggingface.co/docs/tokenizers/api/normalizers

https://huggingface.co/docs/tokenizers/api/pre-tokenizers
https://huggingface.co/docs/tokenizers/api/models
https://huggingface.co/docs/tokenizers/api/post-processors
                                                                            
推荐阅读:
【★】BPE、WordPiece和SentencePiece#1. 背景与基础-简书@ Jarkata【202204】:https://www.jianshu.com/p/d4de091d1367  
【★】大模型基础组件 - Tokenizer #2.切分流程 -知乎@nghuyong【202308】:https://zhuanlan.zhihu.com/p/651430181
【★】大模型词表构建#2.技术基础-博客园@ 努力生活的叶子吖 【202312】:https://www.cnblogs.com/Leahy/p/17808159.html
 【★】优雅谈大模型:Token与分词方法-@庞德公【202406】:https://mp.weixin.qq.com/s/miiC6DEjroPLq33D6sC4BQ

 

                                                                            

【2】subword切分与word,char切分对比

【2】subword切分与word,char切分对比

a.基于词word的切分会造成:1.无法很好的处理未知或罕见的词汇[OOV问题]。2.一定会存在UNK,造成信息丢失。3. 不利于模型学习词缀之间的关系,例如:dog与dogs,happy与unhappy。4.词表中的低频词/稀疏词在模型训练过程中无法得到充分训练,进而模型不能充分理解这些词的语义。


b.基于字符char的切分会造成:1.每个token的粒度太细,丢失了词的语义信息。2.导致模型输入序列过长,解码效率很低,使得模型的训练更加复杂难以收敛。


c.基于subword的切分可以实现:1.词表规模适中,解码效率较高。不存在UNK,信息不丢失。2.能学习到词缀之间的关系。能够较好的平衡OOV问题。


subword的基本切分原则是:1.高频词依旧切分成完整的整词。2.低频词被切分成有意义的子词,例如 dogs => [dog, ##s]。因而subword方法能够大大降低词典的大小,同时对相近词能更好地处理。


                                                                            

【3】Tokenizer在训练,微调模型中使用代码

【3】Tokenizer在训练,微调模型中使用代码

import torch
from datasets import load_dataset
from transformers import (
    AutoModelForCausalLM,AutoTokenizer,    
    BitsAndBytesConfig, HfArgumentParser,   
    TrainingArguments,pipeline,logging,        
)
from peft import LoraConfig, PeftModel
from trl import SFTTrainer


base_model = "Llama2-7b-chat-hf"       # 基础模型路径
guanaco_dataset = "guanaco-llama2-1k"  # 数据集路径
new_model = "llama-2-7b-chat-guanaco"  # 微调模型名称
dataset = load_dataset(guanaco_dataset, split="train"# 加载微调数据集

tokenizer = AutoTokenizer.from_pretrained(base_model, trust_remote_code=True) # 加载tokenizer
tokenizer.pad_token = tokenizer.eos_token    # 序列结束的标记eos_token默认是 [SEP]
tokenizer.padding_side = "right"             # padding_side 设置为right以修复 fp16 的问题

...

#完整微调代码:https://github.com/zzzichen277/LLM_SFT

                                                                            

【4】模拟Tokenizer执行过程代码

【4】模拟Tokenizer执行过程

#kaggle link:https://www.kaggle.com/code/zichen998/chatglm3-6b-tokenizer

from transformers import AutoTokenizer, AutoModel

model_path = "/kaggle/input/chatglm3-6b/pytorch/6b/6"
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
model = AutoModel.from_pretrained(model_path, trust_remote_code=True, device='cuda')
model = model.eval()

text = "你好,我是人工智能助手。"
print(f"1.用户的提问:{text}")

seg_words = tokenizer.tokenize(text)
print(f"2.将用户的提问 分词成token结果:{seg_words}")

seg_word_ids = tokenizer.convert_tokens_to_ids(seg_words)
print(f"3.将用户的提问 分词成token编码ids结果:{seg_word_ids}")


model_inputs = tokenizer([text], return_tensors="pt").to("cuda")
print(f"4.将用户的提问tokenizer后结果:{model_inputs}")

print("###############################################################")

generated_ids = model.generate(model_inputs.input_ids, max_new_tokens=512)
print(f"5.模型返回提问结果 回答token编码ids结果:{generated_ids}")


generated_seg_word = tokenizer.convert_ids_to_tokens(generated_ids[0])
print(f"6.将模型回复编码ids 反编码为token结果:{generated_seg_word}")


response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)
print(f"7.将模型回复反编码ids 反编码为token并合并结果:{response}")

"""
1.用户的提问:你好,我是人工智能助手。
2.将用户的提问 分词成token结果:['▁你', '好', ',', '我是', '人工智能', '助手', '。']
3.将用户的提问 分词成token编码ids结果:[36474, 54591, 31123, 33030, 34797, 42481, 31155]
4.将用户的提问tokenizer后结果:{'input_ids': tensor([[64790, 64792, 36474, 54591, 31123, 33030, 34797, 42481, 31155]],
       device='cuda:0'), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1]], device='cuda:0'), 'position_ids': tensor([[0, 1, 2, 3, 4, 5, 6, 7, 8]], device='cuda:0')}
###############################################################
5.模型返回提问结果 回答token编码ids结果:tensor([[64790, 64792, 36474, 54591, 31123, 33030, 34797, 42481, 31155, 48895,
         38549, 31645, 31404, 42693, 33277, 31639, 40648, 55268, 55353, 36295,
         31514,     2]], device='cuda:0')
6.将模型回复编码ids 反编码为token结果:['[gMASK]', 'sop', '▁你', '好', ',', '我是', '人工智能', '助手', '。', '很高兴', '为您', '服务', '!', '请问', '有什么', '问题', '我可以', '帮', '您', '解答', '?', '']
7.将模型回复反编码ids 反编码为token并合并结果:['[gMASK] sop 你好,我是人工智能助手。很高兴为您服务!请问有什么问题我可以帮您解答?']
"""

                                                                            

【5】训练Tokenizer代码

【5】训练Tokenizer代码

import torch

from datasets import load_dataset
from tokenizers import (decoders,models,normalizers,pre_tokenizers,processors,trainers,Tokenizer,)
from transformers import PreTrainedTokenizerFast

dataset_path = "TurboPascal/tokenizers_example_zh_en"
tokenize_path =  "BPE_tokenizer.json"
# 加载训练数据集
dataset = load_dataset(data_files=dataset_path, cache_dir='./cache/')
def batch_iterator(batch_size=10000):
    for i in range(0, len(dataset), batch_size):
        yield dataset['train'][i: i + batch_size]["text"]

special_tokens = ["[CLS]""[SEP]""[PAD]""[MASK]""<s>""</s>""<t>""</t>"]
trainer = trainers.BpeTrainer(special_tokens=special_tokens, vocab_size=54000)

# 创建BPE tokenizer对象
tokenizer = Tokenizer(models.BPE())
tokenizer.train_from_iterator(batch_iterator(), trainer=trainer, length=len(dataset['train']))

# 保存trained tokenizer
tokenizer.save(tokenize_path)

# 加载trained tokenizer
tokenizer = Tokenizer.from_file(tokenize_path)

output = tokenizer.encode(samplexxx)
print(output.tokens)   

                                                                            


分词算法:BPE【Byte Pair Encoding】

                                                                            

【1】简要介绍BPE

【1】简要介绍BPE
BPE是字符级别的无监督词汇编码算法。BPE构建词典是从字符级词表开始,不断在语料库中找词频最高且连续的token合并再加入词表,直到达到目标词数。


BPE的优点:1)能够解决OOV问题;2)减少词汇表大小;3)具有一定的泛化能力;4可以很有效地平衡词典大小和编码步骤数[将语料编码所需要的token数量]。BPE缺点:1)是基于统计的分词算法,对语料依赖性很强,如果语料规模很小,则效果一般不佳;

BPE是主流采用的subword分词器。经典模型(如GPT、GPT-2、RoBERTa、LLaMA、ChatGLM-6B, Baichuan等)使用BPE作为分词器。


                                                                            

【2】BPE如何构建词典

【2】BPE如何构建词典

BPE[字符级-字节对编码]构建词典是从字符级词表开始,不断在语料库中找词频最高且连续的token合并再加入词表,直到达到目标词数。
BPE的词典构建过程如下:
1.初始化词典:将每个字符视为一个初始的词。
2.统计词频:对于每个词,统计其在文本中的频率。
3.合并频率最高的词对:在每次迭代中,选择频率最高的词对进行合并。合并的方式是将两个词连接起来。
4.更新词频:更新合并后的词频。对于合并的词,统计其在文本中的频率。
5.重复步骤3和4:重复步骤3和4,直到达到预设的词典大小或者满足其他停止条件。每次迭代都会合并频率最高的词对,并更新词频。
通过BPE算法,可以将文本分解为多个子词,其中一些子词可能是常见的词汇,而其他子词则是根据输入文本的特点生成的。这种方式可以更好地处理未登录词和稀有词,并提高模型对复杂词汇和短语的处理能力。
【注:合并频率最高的词对是指所有相邻的tokens组成的pair里频次最高的!】

                                                                            

【2】BPE构建词典模拟代码

【2】BPE构建词典模拟代码

import re, collections

def get_vocab(filename):
    vocab = collections.defaultdict(int)
    with open(filename, 'r', encoding='utf-8') as fhand:
        for line in fhand:
            words = line.strip().split()
            for word in words:
                vocab[' '.join(list(word)) + ' </w>'] += 1
    return vocab

def get_stats(vocab):
    pairs = collections.defaultdict(int)
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols)-1):
            pairs[symbols[i],symbols[i+1]] += freq
    return pairs

def merge_vocab(pair, v_in):
    v_out = {}
    bigram = re.escape(' '.join(pair))
    p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')
    for word in v_in:
        w_out = p.sub(''.join(pair), word)
        v_out[w_out] = v_in[word]
    return v_out

def get_tokens(vocab):
    tokens = collections.defaultdict(int)
    for word, freq in vocab.items():
        word_tokens = word.split()
        for token in word_tokens:
            tokens[token] += freq
    return tokens

vocab = {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w e s t </w>': 6, 'w i d e s t </w>': 3}

# Get free book from Gutenberg
# wget http://www.gutenberg.org/cache/epub/16457/pg16457.txt
# vocab = get_vocab('pg16457.txt')

print('==========')
print('Tokens Before BPE')
tokens = get_tokens(vocab)
print('Tokens: {}'.format(tokens))
print('Number of tokens: {}'.format(len(tokens)))
print('==========')

num_merges = 5
for i in range(num_merges):
    pairs = get_stats(vocab)
    if not pairs:
        break
    best = max(pairs, key=pairs.get)
    vocab = merge_vocab(best, vocab)
    print('Iter: {}'.format(i))
    print('Best pair: {}'.format(best))
    tokens = get_tokens(vocab)
    print('Tokens: {}'.format(tokens))
    print('Number of tokens: {}'.format(len(tokens)))
    print('==========')


#BPE算法详解:https://www.jianshu.com/p/6415a2e9ec09  

                                                                            

【3】BPE实现编码和解码

【3】BPE实现编码和解码
编码:1).在BPE构建词典中得到subword 的词表,对该subword词表按照字符个数由多到少排序。2).编码时,对于每个单词,遍历排好序的subword子词词表寻找是否有 token 是当前单词的子字符串,如果有,则该 token 是表示单词的 tokens 之一。从最长的token迭代到最短的token,尝试将每个单词中的子字符串替换为token。3).最终,在迭代所有的tokens后,将所有子字符串替换为tokens。 如果仍然有子字符串没被替换但所有token都已迭代完毕,则将剩余的子词替换为特殊token,如<unk>。
# 0)给定单词序列
["the</w>""highest</w>""mountain</w>"]
# 1)在BPE构建词典中得到subword 的词表,对该subword词表按照字符个数由多到少排序。
# 长度 6         5           4        4         4       4          2
["errrr</w>""tain</w>""moun""est</w>""high""the</w>""a</w>"]

# 2)编码时,尝试将每个单词中的子字符串替换为token。

# 3)在迭代所有的tokens后,将所有子字符串替换为tokens。返回迭代结果
"the</w>" -> ["the</w>"]
"highest</w>" -> ["high""est</w>"]
"mountain</w>" -> ["moun""tain</w>"]
解码:如果相邻token间没有</w>中止符,则将两token直接拼接,否则两token之间添加分隔符。 如果仍然有子字符串没被替换但所有 token 都已迭代完毕,则将剩余的子词替换为特殊 token,如<unk>。
# 编码序列
["the</w>""high""est</w>""moun""tain</w>"]
# 解码序列
"the</w> highest</w> mountain</w>"

【★】BPE算法详解-简书@ Jarkata【202202】:https://www.jianshu.com/p/6415a2e9ec09


                                                                            

【4】BPE代码实现-创建BPE Tokenizer

【4】BPE代码实现-创建BPE Tokenizer

from tokenizers import (decoders,models,normalizers,pre_tokenizers,processors,trainers,Tokenizer,)
from transformers import PreTrainedTokenizerFast
from datasets import Dataset


# Creating Byte-Pair Encoding tokenizer         
raw_tokenizer = Tokenizer(models.BPE(unk_token="[UNK]"))                                                              #设置Tokenizer
raw_tokenizer.normalizer = normalizers.Sequence([normalizers.NFC()] + [normalizers.Lowercase()] if LOWERCASE else []) #normalizers
raw_tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel()    
                                                          #pre_tokenizers

special_tokens = ["[UNK]""[PAD]""[CLS]""[SEP]""[MASK]"]
trainer = trainers.BpeTrainer(vocab_size=VOCAB_SIZE, special_tokens=special_tokens)                                   #trainers

dataset = Dataset.from_pandas(test[['text']])                                                                         #Dataset
def train_corp_iter(): 
    for i in range(0, len(dataset), 25):
        yield dataset[i : i + 25]["text"]

raw_tokenizer.train_from_iterator(train_corp_iter(), trainer=trainer)

tokenizer = PreTrainedTokenizerFast(                                                                                  #PreTrainedTokenizerFast
    tokenizer_object=raw_tokenizer,
    unk_token="[UNK]",pad_token="[PAD]",cls_token="[CLS]",sep_token="[SEP]",mask_token="[MASK]",
)

tokenized_texts_test = []
for text in tqdm(test['text'].tolist()):
    tokenized_texts_test.append(tokenizer.tokenize(text))
tokenized_texts_train = []
for text in tqdm(train['text'].tolist()):
    tokenized_texts_train.append(tokenizer.tokenize(text))


                                                                            

【5】BPE代码实现-BPE 构建词典过程

【5】BPE代码实现-BPE构建词典过程

import re, collections

def get_stats(vocab):
    pairs = collections.defaultdict(int)
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols)-1):
            pairs[symbols[i],symbols[i+1]] += freq
    return pairs

def merge_vocab(pair, v_in):
    v_out = {}
    bigram = re.escape(' '.join(pair))
    p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')
    for word in v_in:
        w_out = p.sub(''.join(pair), word)
        v_out[w_out] = v_in[word]
    return v_out

vocab = {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w e s t </w>': 6, 'w i d e s t </w>': 3}
num_merges = 1000
for i in range(num_merges):
    pairs = get_stats(vocab)
    if not pairs:
        print(f"第{i}轮合并结束")
        break
        
    best = max(pairs, key=pairs.get)
    vocab = merge_vocab(best, vocab)
    print(f"第{i}轮合并,best-pair值为{best},|||合并后vocab为{vocab}")
    
"""
第0轮合并,best-pair值为('e', 's'),|||合并后vocab为{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w es t </w>': 6, 'w i d es t </w>': 3}
第1轮合并,best-pair值为('es', 't'),|||合并后vocab为{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est </w>': 6, 'w i d est </w>': 3}
第2轮合并,best-pair值为('est', '</w>'),|||合并后vocab为{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3}
第3轮合并,best-pair值为('l', 'o'),|||合并后vocab为{'lo w </w>': 5, 'lo w e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3}
第4轮合并,best-pair值为('lo', 'w'),|||合并后vocab为{'low </w>': 5, 'low e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3}
第5轮合并,best-pair值为('n', 'e'),|||合并后vocab为{'low </w>': 5, 'low e r </w>': 2, 'ne w est</w>': 6, 'w i d est</w>': 3}
第6轮合并,best-pair值为('ne', 'w'),|||合并后vocab为{'low </w>': 5, 'low e r </w>': 2, 'new est</w>': 6, 'w i d est</w>': 3}
第7轮合并,best-pair值为('new', 'est</w>'),|||合并后vocab为{'low </w>': 5, 'low e r </w>': 2, 'newest</w>': 6, 'w i d est</w>': 3}
第8轮合并,best-pair值为('low', '</w>'),|||合并后vocab为{'low</w>': 5, 'low e r </w>': 2, 'newest</w>': 6, 'w i d est</w>': 3}
第9轮合并,best-pair值为('w', 'i'),|||合并后vocab为{'low</w>': 5, 'low e r </w>': 2, 'newest</w>': 6, 'wi d est</w>': 3}
第10轮合并,best-pair值为('wi', 'd'),|||合并后vocab为{'low</w>': 5, 'low e r </w>': 2, 'newest</w>': 6, 'wid est</w>': 3}
第11轮合并,best-pair值为('wid', 'est</w>'),|||合并后vocab为{'low</w>': 5, 'low e r </w>': 2, 'newest</w>': 6, 'widest</w>': 3}
第12轮合并,best-pair值为('low', 'e'),|||合并后vocab为{'low</w>': 5, 'lowe r </w>': 2, 'newest</w>': 6, 'widest</w>': 3}
第13轮合并,best-pair值为('lowe', 'r'),|||合并后vocab为{'low</w>': 5, 'lower </w>': 2, 'newest</w>': 6, 'widest</w>': 3}
第14轮合并,best-pair值为('lower', '</w>'),|||合并后vocab为{'low</w>': 5, 'lower</w>': 2, 'newest</w>': 6, 'widest</w>': 3}
第15轮合并结束
"""


                                                                            

【5】通过例子说明BPE构建词典

【5】通过例子说明BPE构建词典

假设有语料集经过统计后表示为{'low':5,'lower':2,'newest':6,'widest':3},其中数字代表的是对应单词在语料中的频数。

输入【语料】:{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w e s t </w>': 6, 'w i d e s t </w>': 3}。此时词表:{'l' , 'o' ,'w' ,'e' ,'r' ,'n' ,'s' ,'t' ,'i' ,'d' ,'</w>' ,}

Iter 1, 最高频连续字节对"e""s"出现了6+3=9次,合并成"es"。输出【新语料】:{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w es t </w>': 6, 'w i d es t </w>': 3}。【新词表】:{'l' , 'o' ,'w' ,'e' ,'r' ,'n' ,'s' ,'t' ,'i' ,'d' ,'</w>' ,'es' ,}。

Iter 2, 最高频连续字节对"es""t"出现了6+3=9次, 合并成"est"。输出【新语料】:{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est </w>': 6, 'w i d est </w>': 3}。【新词表】:{'l' , 'o' ,'w' ,'e' ,'r' ,'n' ,'t'  ,'i' ,'d' ,'</w>' ,'es','est' ,}。

Iter 3, 以此类推,最高频连续字节对为"est""</w>" 。输出【新语料】:{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3}。【新词表】:{'l' , 'o' ,'w' ,'e' ,'r' ,'n'  ,'i' ,'d' ,'</w>' ,'est' ,'est</w>',}。

……

Iter n, 继续迭代直到达到预设的subword词表大小或下一个最高频的字节对出现频率为1。

注:停止符"</w>"的意义在于表示subword是词后缀。举例来说:"st"字词不加"</w>"可以出现在词首如"st ar",加了"</w>"表明该字词位于词尾,如"wide st</w>",二者意义截然不同。

                                                                            
推荐阅读:
【★】理解NLP最重要的编码方式 (BPE)-知乎@Jinjie Ni【202304】:https://zhuanlan.zhihu.com/p/424631681
【★】BPE算法详解-简书@ Jarkata【202202】:https://www.jianshu.com/p/6415a2e9ec09
【★】NLP三大Subword模型详解:BPE、WordPiece、ULM#BPE构建词典过程模拟-知乎@ 阿北【202008】:https://zhuanlan.zhihu.com/p/191648421


                                                                            

【6】BBPE和BPE的区别

【6】BBPE和BPE的区别

BPE的问题是,如果遇到了unicode,基本字符集可能会很大。一种处理方法BBPE是以一个字节为一种“字符”,不管实际字符集用了几个字节来表示一个字符。这样的话,基础字符集的大小就锁定在了256。例如,像GPT-2的词汇表大小为50257 = 256 + <EOS> + 50000 mergers,<EOS>是句子结尾的特殊标记。

BBPE是BPE的扩展版本,BBPE核心思想将BPE的从字符级别扩展到字节级别。即字节级 BPE 将所有 Unicode 代码点转换为多个字节级字符)。LLaMA,ChatGLM...都基于BBPE实现。


使用BBPE有什么好处?使用Byte-level BPE主要优点是:较小的词汇表。没有未知token,不会出现OOV问题。



特点Character Set| 字符集
传统BPE-字符级别BPE。传统的BPE基于char粒度去执行合并的过程生成词表。

ASCII编码--)

传统BPE使用1个字节对字符进行编码

以字符粒度的编码

BBPE-字节级别的BPE。

BBPE是基于字节粒度 去执行合并过程生成词表。

BBPE能比较好支持语料是多种语言的分词。

(Unicode编码-改进utf-8编码)

BBPE使用1~4个字节对字符进行编码

以字节粒度的编码

对于英文、拉美体系的语言来说使用BPE分词足以在可接受的词表大小下解决OOV的问题。但面对中文、日文等语言时,其稀有的字符可能会不必要地占用词汇表,因此考虑使用字节级别byte-level解决不同语言进行分词时OOV的问题。
BBPE在不同词表大小下的对日文、英语的编码结果
                                                                            
推荐阅读:
【★】1.深入理解NLP Subword算法:BPE、WordPiece、ULM-知乎@Luke【201910】:https://zhuanlan.zhihu.com/p/86965595
【★】2.BPE 算法原理及使用指南【深入浅出】-知乎@Suprit【202112】:https://zhuanlan.zhihu.com/p/448147465
【★】3.BPE、WordPiece和SentencePiece-简书@ Jarkata【202204】:https://www.jianshu.com/p/d4de091d1367
【★】3x【HugBert11】聚沙成塔:关于tokenization(词元化)的解疑释惑【202105】:https://zhuanlan.zhihu.com/p/371300063
【★】4.大模型系列:BPE理论简述和实践【202405】:https://blog.csdn.net/2401_85325397/article/details/139472993
【★】5.理解NLP最重要的编码方式 (BPE)-知乎@Jinjie Ni【202304】:https://zhuanlan.zhihu.com/p/424631681
【★】5xBPE算法详解-简书@ Jarkata【202202】:
https://www.jianshu.com/p/6415a2e9ec09 
【★】6.NLP三大Subword模型详解:BPE、WordPiece、ULM-知乎 @阿北 【202008】:https://zhuanlan.zhihu.com/p/191648421
                                                                            


分词算法:WordPiece


                                                                            

【1】简要介绍WordPiece

【1】简要介绍WordPiece

WordPiece算法可以看作是BPE的变种。每次从词表中选出两个子词合并成新的子词。不同点在于,WordPiece基于 下个最高互信息值的pair 生成新的subword 而不是下个最高频pair。

v2:WordPiece分词与BPE非常类似,只是在训练阶段合并pair的策略不是pair的频率而是互信息。
Score=log(P(AB))−[log(P(A))+log(P(B))]=log( P(AB)/P(A)*P(B) )。

WordPiece每次合并的两个字符串A和B,应该具有最大的P(AB)/P(A)*P(B)值。合并AB之后,所有原来切成A+B两个tokens的就只保留AB一个token,整个训练集上最大似然变化量与P(AB)/P(A)*P(B)成正比。

                                                                            

【2】WordPiece是如何选取子词的

【2】WordPiece是如何选取子词的

WordPiece每次选择合并的两个子词具有最大的互信息值,即两个子词在语言模型上具有较强的关联性,它们经常在语料中以相邻方式同时出现。

                                                                            

【3】WordPiece 与 BPE 的异同点是什么

【3】WordPiece 与 BPE 的异同点是什么


BPE(Byte-Pair Encoding)

WordPiece

相似点:

分词目标:WordPiece和BPE都旨在将文本分解为子词或字符级别的单位,以便更好地处理未登录词和稀有词,提高模型对复杂词汇和短语的处理能力。

无监督学习:WordPiece和BPE都是无监督学习方法,不需要依赖外部的标注数据,而是通过分析输入文本自动构建词典。

不同点:

拆分策略

BPE则采用自底向上的拆分策略,通过合并频率最高的词对来构建词典。它使用词频来选择合并的词对,并通过更新词频来更新词典。

WordPiece采用贪婪的自顶向下的拆分策略,将词汇表中的词分解为更小的子词。它使用最大似然估计来确定最佳的分割点,并通过词频来更新词典。

不同点:

分割粒度

BPE则将词分解为更小的子词或字符级别的单位。它不使用特殊的前缀或后缀来表示子词。

WordPiece通常将词分解为更小的子词,例如将"running"分解为"run"和"##ning"。这些子词通常以"##"前缀表示它们是一个词的一部分。 

不同点:

处理未登录词

BPE则将未登录词作为单独的词处理,不进行进一步的拆分。

WordPiece通常将未登录词分解为更小的子词,以便模型可以更好地处理它们。

典型模型

典型使用BPE模型有:GPT-2 与RoBERTa

典型使用WordPiece模型有:Bert/Electra/DistilBERT。


                                                                            

【4】WordPiece代码实现-Creating WordPiece Tokenizer

【4】WordPiece代码实现-Creating WordPiece Tokenizer

from tokenizers import (decoders,models,normalizers,pre_tokenizers,processors,trainers,Tokenizer,)
from transformers import PreTrainedTokenizerFast
from datasets import Dataset


# Creating WordPiece tokenizer         
raw_tokenizer = Tokenizer(models.WordPiece())  
                                                                       #models
raw_tokenizer.normalizer = normalizers.Sequence([normalizers.NFC()] + [normalizers.Lowercase()] if LOWERCASE else []) #normalizers
raw_tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel()                                                              #pre_tokenizers
special_tokens = ["[UNK]""[PAD]""[CLS]""[SEP]""[MASK]"]

trainer = trainers.BpeTrainer(vocab_size=VOCAB_SIZE, special_tokens=special_tokens)                                   #trainers
...
                                                                            

推荐阅读:

【★】1.LLM 分词算法 (BPE, WordPiece, Unigram) 简介【202311】:https://zhuanlan.zhihu.com/p/664717335

【★】2.BPE、WordPiece和SentencePiece-简书@ Jarkata【202204】:https://www.jianshu.com/p/d4de091d1367 -

【★】3.NLP三大Subword模型详解:BPE、WordPiece、ULM【202008】:https://zhuanlan.zhihu.com/p/191648421   

                                                                            


分词算法:Unigram【Unigram Language Model】


                                                                            

【1】简要介绍ULM

【1】简要介绍ULM

与BPE和WordPiece不同,Unigram算法是从预分词器分的词+所有高频的子词构成的大词汇表出发,再逐步删除其中重要性较低的subword,直到满足预定义size。

Unigram语言模型通过计算 删除不同subword造成的损失loss 来衡量subword的重要性,删除loss较小或者说重要性较低的subword。每次从词汇表中删除词汇的原则是使预定义的损失loss最小。


训练时,计算loss的公式为:


Unigram算法每次会从词汇表中挑出使得loss增长最小的10%~20%的词汇来删除。


                                                                            

理论基础:假设训练文档中的所有词分别为(x1,x2,x3...,xn),由n个子词组成 ,而每个词tokenize的方法是对应集合S(xi)。

当词汇表确定时,每个词tokenize的方法集合S(xi)就是确定的,而每种方法对应着一个概率p(x)。
如果从词汇表中删除部分词,则某些词的tokenize的种类集合就会变少,log(*)中的求和项就会减少,从而增加整体loss【删除重要的subword对loss增长影响大,不删除】Unigram算法每次会从词汇表中挑出使得loss增长最小的10%~20%的词汇来删除。

ULM使用大量符号初始化基础词汇,并逐渐修剪每个符号以获得较小的词汇。在每个训练步骤中,计算当前词汇和unigram语言模型给定训练数据的损失。移除影响整体损失最小的符号,重复此过程直到词汇达到所需大小。Unigram保留基本字符,以便能够对任何单词进行分词。

                                                                            

ULM用EM算法求解每个子词subword在语料上的概率?(??)。

假设当前词表V, 语料库中语料数量是|D|,则M步最大化的对象是如下似然函数:

ULM会保留那些以较高频率出现在很多句子的分词结果中的子词,因为这些子词如果被丢弃,其损失会很大。

【★】Subword Tokenization算法-@DonngZH【202303】:https://blog.csdn.net/weixin_44750512/article/details/129435981

【★】NLP三大Subword模型详解:BPE、WordPiece、ULM-知乎 @阿北 【202008】:https://zhuanlan.zhihu.com/p/191648421


                                                                            

【2】ULM如何来构造词表以及求解分词概率

【2】ULM算法如何来构造词表以及求解分词概率

输入:训练语料;词表大小V; 保留阈值X;输出:ULM算法得到的subword词表。

ULM算法采用不断迭代的方法来构造词表以及求解分词概率:

1.准备基础词表:初始时建立一个足够大的词表。可用语料中的所有字符+常见的子字符串 初始化词表,也可以通过BPE算法初始化。

2.针对当前词表,用EM算法求解每个子词subword在语料上的概率。

3.对于每个子词,计算当该子词被从词表中移除时,总的loss降低了多少,记为该子词的loss。

4.将子词按照loss大小进行排序,丢弃一定比例loss最小的子词(比如20%),保留前X%的子词生成新的词表。注意单字符不能被丢弃,为避免OOV情况。

5.重复步骤2到4,直到词表大小减少到设定范围V或第4步的结果不再变化。


可以看出,ULM会保留那些以较高频率出现在很多句子的分词结果中的子词,因为这些子词如果被丢弃,其损失会很大。


【★】NLP三大Subword模型详解:BPE、WordPiece、ULM#Unigram Language Model (ULM)-知乎 @阿北 【202008】:https://zhuanlan.zhihu.com/p/191648421  


                                                                            
推荐阅读:
【★】1.Unigram-Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates:https://arxiv.org/abs/1804.10959
【★】2.NLP三大Subword模型详解:BPE、WordPiece、ULM#Unigram Language Model (ULM)-知乎 @阿北 【202008】:https://zhuanlan.zhihu.com/p/191648421   
【★】3.Subword Tokenization算法-@DonngZH【202303】:https://blog.csdn.net/weixin_44750512/article/details/129435981
                                                                            



分词工具:SentencePiece

                                                                            

【1】简要介绍SentencePiece

【1】简要介绍SentencePiece

SentencePiece是分词工具,内置BPE,Unigram等多种分词方法,基于Unicode编码并且将空格视为特殊的token。当前主流的大模型都是基于SentencePiece实现,例如ChatGLM的tokenizer。

SentencePiece 在大模型领域具有以下优势:分词效果好速度快,能够准确地识别词语和符号的边界。编码效率高,能够节省空间。能够将词语或符号之间的关系编码到编码中,有利于模型学习。
SentencePiece用于处理不使用空格分隔单词的语言,如中文、日文和泰文。
...
class TextTokenizer:
    def __init__(self, model_path):
        self.sp = spm.SentencePieceProcessor()
        self.sp.Load(model_path)
        self.num_tokens = self.sp.vocab_size()

    def encode(self, text):
        return self.sp.EncodeAsIds(text)

    def decode(self, ids: List[int]):
        return self.sp.DecodeIds(ids)
...

#https://huggingface.co/THUDM/chatglm-6b/blob/main/tokenization_chatglm.py#L21

                                                                            

【1】SentencePiece特性

【1】SentencePiece 特性

1.唯一Token数量是预先确定的; 与大多数假设无限词汇量的无监督分词算法不同,SentencePiece 在训练分词模型时,使最终的词汇表大小固定,例如:8k、16k 或 32k。
2.可以从原始句子进行训练;以前的子词(sub-word)实现假设输入句子是预标记(pre-tokenized)的。 这种约束是有效训练所必需的,但由于我们必须提前运行依赖于语言的分词器,因此使预处理变得复杂。SentencePiece 的实现速度足够快,可以从原始句子训练模型。 这对于训练中文和日文的tokenizer和detokenizer很有用,因为在这些词之间不存在明确的空格。
3.空格被视为基本符号;SentencePiece 将输入文本视为一系列 Unicode 字符。 空格也作为普通符号处理。 为了明确地将空格作为基本标记处理,SentencePiece 首先使用元符号 "▁" (U+2581) 转义空格。


                                                                            

【1】SentencePiece技术优势

【1】SentencePiece技术优势

1.纯数据驱动:SentencePiece 从句子中训练 tokenization 和 detokenization 模型。 并不总是需要Pre-tokenization(Moses tokenizer/MeCab/KyTea) 。
2.独立于语言:SentencePiece 将句子视为 Unicode 字符序列。 没有依赖于语言的逻辑。
3.多子词算法:支持 BPE 和 unigram 语言模型。
4.子词正则化:SentencePiece 实现子词正则化和 BPE-dropout 的子词采样,有助于提高 NMT 模型的鲁棒性和准确性。
5.快速且轻量级:分割速度约为 50k 句子/秒,内存占用约为 6MB。
6.Self-contained:只要使用相同的模型文件,就可以获得相同的tokenization/detokenization。
7.直接从词汇 ID 生成:SentencePiece 管理词汇到 ID 的映射,可以直接从原始句子生成词汇 ID 序列。
8.基于 NFKC 的 normalization:SentencePiece 执行基于 NFKC 的文本 normalization。

 【★】转载于:大模型词表扩充必备工具SentencePiece# 训练模型-知乎@吃果冻不吐果冻皮【202305】:https://zhuanlan.zhihu.com/p/630696264  


                                                                            

【2】使用SentencePiece训练分词器

【2】使用Sentencepiece训练分词器

##v1 Train a BPE Model
import sentencepiece as spm

# train sentencepiece model from our blog corpus
spm.SentencePieceTrainer.train(input='blog_test.txt',model_prefix=bpe --vocab_size=500, user_defined_symbols=['foo''bar'])

# makes segmenter instance and loads the BPE model file (bpe.model)
sp_bpe = spm.SentencePieceProcessor()
sp_bpe.load('bpe.model')

####################################################################################
##v2 Train a Unigram Model
import sentencepiece as spm

# train sentencepiece model from our blog corpus
spm.SentencePieceTrainer.train(input='blog_test.txt',model_prefix=bpe --vocab_size=500, user_defined_symbols=['foo''bar'])

# makes segmenter instance and loads the BPE model file (bpe.model)
sp_uni = spm.SentencePieceProcessor()
sp_uni.load('uni.model')

####################################################################################
## 比较一下两个分词器结果
print("BPE: {}".format(sp_bpe.encode_as_pieces('This is a test')))
print("UNI: {}".format(sp_uni.encode_as_pieces('This is a test')))
BPE: ['▁This''▁is''▁a''▁t''est']
UNI: ['▁Thi''s''▁is''▁a''▁t''est']

#https://zhuanlan.zhihu.com/p/620508648

                                                                            

【3】使用SentencePiece加载和使用词表模型文件tokenizer.model

【3】使用SentencePiece加载和使用词表模型文件tokenizer.model

ChatGLM3-6B中tokenizer.model模型是使用SentencePiece训练得到的词表模型文件。

tokenization_chatglm.py:分词器的 .py 文件,用于模型分词器,加载和使用模型的必要部分(只是处理文本,不涉及任何向量操作)。
tokenizer.model:包含了训练好的分词模型,用于将输入文本转换为标记序列;二进制使用 pickle 或者其他序列化工具进行存储和读取。
tokenizer_config.json:分词模型的配置信息,用于指定分词模型的超参和其他的相关信息,例如分词器的类型、词汇表大小、最大序列长度、特殊标记等。  
from sentencepiece import SentencePieceProcessor
model_path = "/kaggle/input/chatglm3-6b/pytorch/6b/6/tokenizer.model"
sp_model = SentencePieceProcessor(model_file=model_path)
text = "你好,你是谁?"

tokens = sp_model.EncodeAsPieces(text)
print(f"1.句子转为tokens结果: {tokens}")

ids = sp_model.EncodeAsIds(text)
print(f"2.句子转为ids结果: {ids}")
decode_text = sp_model.Decode(ids)
print(f"3.ids转为句子结果:{decode_text}")

#1.句子转为tokens结果: ['▁你', '好', ',', '你是', '谁', '?']
#2.句子转为ids结果: [36474, 54591, 31123, 34607, 55622, 31514]
#3.ids转为句子结果:你好,你是谁?

                                                                            

【4】使用SentencePiece对LLM词表扩充

【4】使用SentencePiece对LLM词表扩充

使用sentencepiece训练模型:

#0.从 C++ 源构建和安装 SentencePiece 命令行工具
>sudo apt-get install cmake build-essential pkg-config libgoogle-perftools-dev
>git clone https://github.com/google/sentencepiece.git 
>cd sentencepiece | mkdir build|  cd build  | cmake ..   |  make -j $(nproc)  | make install   |   ldconfig -v 

#1.spm_train进行模型训练 spm_train --input=训练语料文件 --model_prefix=输出模型名称前缀 --vocab_size=训练后的词表大小 --character_coverage=模型覆盖的字符数量 --model_type=模型类型如bpe
>spm_train --input=/workspace/data/book/hongluomeng_clean.txt --model_prefix=/workspace/model/book/hongluomeng-tokenizer --vocab_size=4000 --character_coverage=0.9995 --model_type=bpe

#2.模型输出文件(词表及模型权重)
>ls -al /workspace/model/book

#3.查看词表:
>head -n20 /workspace/model/book/hongluomeng-tokenizer.vocab

#4.基于命令行使用模型,将原始文本编码成句子片段(token)。
echo "白日依山尽,黄河入海流。" | spm_encode --model=/workspace/model/book/hongluomeng-tokenizer.model  #▁ 白 日 依 山 尽 , 黄 河 入 海 流 。
echo "白日依山尽,黄河入海流。" | spm_encode --model=/workspace/model/book/hongluomeng-tokenizer.model --output_format=id  #60 254 70 333 468 400 14 733 1476 317 603 510 15

#将句子片段(token) id 解码为原始文本。
echo "60 254 70 333 468 400 14 733 1476 317 603 510 15" | spm_decode --model=/workspace/model/book/hongluomeng-tokenizer.model --input_format=id  #白日依山尽,黄河入海流。

#5.spm_export_vocab基于模型文件导出词汇表。# spm_export_vocab --model=<模型文件> --output=<输出文件>
> spm_export_vocab --model=/workspace/model/book/hongluomeng-tokenizer.model --output=/workspace/output/hongluomeng.vocab
                                                                            
使用sentencepiece使用模型:
import sentencepiece as spm

sp = spm.SentencePieceProcessor()

text="这贾雨村原系胡州人氏,也是诗书仕宦之族,因他生于末世,父母祖宗根基已尽,人口衰丧,只剩得他一身一口,在家乡无益,因进京求取功名,再整基业。"

sp.Load("hongluomeng-tokenizer.model")

print(sp.EncodeAsPieces(text))
['▁''这''贾''雨''村''原''系''胡''州''人''氏'',''也''是''诗''书''仕''宦''之''族'',''因''他''生''于''末''世'',''父''母''祖''宗''根''基''已''尽'',''人''口''衰''丧'',''只''剩''得''他''一''身''一''口'',''在''家''乡''无''益'',''因''进''京''求''取''功''名'',''再''整''基''业''。']
【★】转载于:大模型词表扩充必备工具SentencePiece# 训练模型-掘金@吃果冻不吐果冻皮【202305】:https://zhuanlan.zhihu.com/p/630696264  
                                                                            

【5】SentencePiece-byte回退

【5】SentencePiece-byte回退

当SentencePiece在训练BPE的时开启--byte_fallback, 在效果上类似BBPE,遇到UNK会继续按照byte进行进一步的切分。
参见:https://github.com/google/sentencepiece/issues/621 
具体实现上是将<0x00> ... <0xFF>这256个token添加到词表中。
分析ChatGLM的模型,可以发现ChatGLM就是开启了--byte_fallback。
from sentencepiece import sentencepiece_model_pb2

m = sentencepiece_model_pb2.ModelProto()
with open('chatglm-6b/ice_text.model''rb') as f:
    m.ParseFromString(f.read())
print('ChatGLM tokenizer\n\n'+str(m.trainer_spec))

"""
ChatGLM tokenizer
input: "
/root/train_cn_en.json"
model_prefix: "
new_ice_unigram"
vocab_size: 130000
character_coverage: 0.9998999834060669
split_digits: true
user_defined_symbols: "
<n>"
byte_fallback: true
pad_id: 3
train_extremely_large_corpus: true
"""

可以看到 byte_fallback: true。同样的方法,可以验证LLaMA, ChatGLM-6B, Baichuan这些大模型都是基于sentencepiece实现的BPE的分词算法,并且采用byte回退。

【★】大模型基础组件Tokenizer-SentencePiece @nghuyong【202308】:https://zhuanlan.zhihu.com/p/651430181


                                                                            

【6】和SentencePiece类似的工具

【6】和SentencePiece类似的工具

SentencePiece英文分词工具,使用无监督学习的分词方法。基于BPE编码的编码方法 。分词效果好,编码效率高,但需要训练模型。

Jieba中文分词工具,使用最大似然法的方法进行分词。基于哈希编码的编码方法。Jieba的分词效果较好,并且速度较快,但需要人工制定分词规则。

Hmmseg中文分词工具,使用隐马尔可夫模型的方法进行分词。基于哈希编码的编码方法。Hmmseg的分词效果较好,并且可以支持多种语言,但需要训练模型。

Stanford CoreNLP自然语言处理工具包,包含分词、词性标注、句法分析等功能。使用基于规则的方法分词。基于哈希编码的编码方法。Stanford CoreNLP 的分词效果较好,并且可以支持多种语言。

Tiktoken:基于BPE算法的快速分词器,专门针对GPT-4ChatGPT等大模型进行了优化。Tiktoken 的主要特点是分词速度比 SentencePiece 快很多,分词效果与SentencePiece相当,提供简单的 API接口,方便使用。


                                                                            

【7】分词器使用建议

分词器使用建议:

1.如果需要对中文文本进行分词,并且对分词效果要求较高,可以选择 SentencePiece、Jieba 或 Hmmseg。如果需要对多种语言文本进行分词,可以选择 Stanford CoreNLP。
2.如果需要对文本进行分词和编码,并且对速度要求较高,可以选择 Jieba。对分词效果要求较高,可以选择 SentencePiece 或 Hmmseg。

【★】大模型分词:sentencepiece vs titoken-知乎@王几行【202404】:https://zhuanlan.zhihu.com/p/691609961

                                                                            

推荐阅读:

【★】1.SentencePiece:https://arxiv.org/pdf/1808.06226.pdf

【★】2.Github:https://github.com/google/sentencepiece

【★】3.大模型分词:sentencepiece vs titoken-知乎@王几行【202404】:https://zhuanlan.zhihu.com/p/691609961

【★】4.大模型词表扩充必备工具SentencePiece-掘金@吃果冻【202305】:https://zhuanlan.zhihu.com/p/630696264     

                                                                            


Toknenizer相关面试题总结

                                                                            

【X】简要介绍常用Tokenizer的原理和区别

【X】简要介绍常用Tokenizer的原理和区别

1.BPE构建词典从字符级词表开始,不断在语料库中找词频最高且连续的token合并加入词表,直到达到目标词数。生成大小可控的开放词汇表。

2.WordPiece分词与BPE非常类似,只是在训练阶段合并pair的策略不是pair的频率而是互信息。?????=???(?(??))−(???(?(?))+???(?(?)))=???(?(??)/?(?)?(?))。

3.与BPE和WordPiece不同,Unigram算法是从预分词器分的词+所有高频的子词构成的大词汇表出发,再逐步删除其中重要性较低的subword,直到满足预定义size。


                                                                            

【X】BBPE和BPE的区别

【X】BBPE和BPE的区别
BPE的问题是,如果遇到了unicode,基本字符集可能会很大。一种处理方法BBPE是以一个字节为一种“字符”,不管实际字符集用了几个字节来表示一个字符。这样的话,基础字符集的大小就锁定在了256例如,像GPT-2的词汇表大小为50257 = 256 + <EOS> + 50000 mergers,<EOS>是句子结尾的特殊标记。
BBPE是BPE的扩展版本,BBPE核心思想将BPE的从字符级别扩展到字节级别。即字节级 BPE 将所有 Unicode 代码点转换为多个字节级字符)。LLaMA,ChatGLM...都基于BBPE实现。
使用BBPE有什么好处?使用BBPE主要优点是:较小的词汇表。没有未知token,不会出现OOV问题。


·EN
 ■ ■ 


53AI,企业落地应用大模型首选服务商

产品:大模型应用平台+智能体定制开发+落地咨询服务

承诺:先做场景POC验证,看到效果再签署服务协议。零风险落地应用大模型,已交付160+中大型企业

联系我们

售前咨询
186 6662 7370
预约演示
185 8882 0121

微信扫码

与创始人交个朋友

回到顶部

 
扫码咨询