赞
踩
目前大模型的词表和分词器都是基于SentencePiece工具实现的,比如LLaMa,BLOOM,ChatGLM,Baichuan等,简单来说SentencePiece就是工程化的实现了之前写的各种的分词算法,而且实现的十分优雅,简单,快速,轻量。本文主要参考官方的Git[1],进行对这个工具的使用,以及自己在扩充LLaMa中文词表实验过程中的一些问题做记录。全文阅读和实现可能需要30分钟,建议收藏~如果觉得对你有帮助,那就点个赞吧 。
一些想法和总结写在前面,使用SentencePiece的除了从0开始训练大模型的土豪和大公司外,大部分应该都是使用其为当前开源的大模型扩充词表,比如为LLaMa扩充通用中文词表(垂直领域词表),为开源的中文大模型ChatGLM,Baichuan扩充垂直领域词表。那这部分工作有没有意义呢?或者说值不值得投入资源去做呢?先说自己的结论,有,以下两点的作用,第三点不确定:
1.提高模型的编解码的效率,在LLaMa原来的词表上,一个汉字平均1.45个token,扩充后的Chinese-LLaMa为0.65个token[2];那在垂直领域内呢?比如在LLaMa在继续扩充领域内词表,金融或者医疗等等,把“负债表”,“糖尿病”等领域词汇也加入词表里,那更加能提高其编解码的效率。
2.提高模型的上下文窗口长度,原LLaMa上下文长度是4096个token,不扩充词表前,按1.45来算就是最多只能输入2824个汉字,扩充后以0.65来算的话就是6301,垂直领域会更大。这点带来的好处是实打实的。
3.提高模型的效果?提高LLaMa在中文的表现?提高开源模型在垂直领域的表现?这一点上难以下结论,目前好像也没有确定的结论,自我感觉会有,但是不多,而且可能在垂直领域扩充词表后,垂直领域词太多过拟合影响通用领域效果,还有就是扩充完词表后还要经过一系列的后续处理和训练,可以控制变量的研究一下,但需要很多的资源哈哈。但是前两点的好处是实打实的,所以在有资源的情况下,扩充词表还是可以尝试的。
SentencePiece的安装方式有两种,实现的效果是一样的。
在linux Ubuntu 上,命令行执行:
sudo apt-get update
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)
sudo make install
# linux
sudo ldconfig -v
# OSX/macOS
sudo update_dyld_shared_cache
亲测安装没有问题,验证安装是否成功:
spm_train --help
通过pip安装,应该是最简单和快速的方式,实现的功能和上诉源码安装是一模一样的:
pip install sentencepiece
安装完成后,我们开始使用,第一步是训练词表,用起来很简单,源码安装的方式直接命令行执行
spm_train --input=<input> --model_prefix=<model_name> --vocab_size=8000 --character_coverage=1.0 --model_type=<type>
python的方式则是:
import sentencepiece as spm
spm.SentencePieceTrainer.train(input=<input>, model_prefix=<model_name>, vocab_size=8000, character_coverage=1.0, model_type=<type>)
不过上述方式都建议写一个sh脚本,通过nohup或者screen放在后台执行,使用起来是很简单,但是这个训练的参数有接近40个,本着使用一个工具就尽量研究明白的态度,把主要的参数都进行了解释和一些不确定的参数用途的进行了实验。
以下是官方给出的训练参数解释,后面是笔者通过实验的一些理解。
--input (comma separated list of input sentences) type: std::string default: "" --input_format (Input format. Supported format is `text` or `tsv`.) type: std::string default: "" --model_prefix (output model prefix) type: std::string default: "" --model_type (model algorithm: unigram, bpe, word or char) type: std::string default: "unigram" --vocab_size (vocabulary size) type: int32 default: 8000 --accept_language (comma-separated list of languages this model can accept) type: std::string default: "" --self_test_sample_size (the size of self test samples) type: int32 default: 0 --character_coverage (character coverage to determine the minimum symbols) type: double default: 0.9995 --input_sentence_size (maximum size of sentences the trainer loads) type: std::uint64_t default: 0 --shuffle_input_sentence (Randomly sample input sentences in advance. Valid when --input_sentence_size > 0) type: bool default: true --seed_sentencepiece_size (the size of seed sentencepieces) type: int32 default: 1000000 --shrinking_factor (Keeps top shrinking_factor pieces with respect to the loss) type: double default: 0.75 --num_threads (number of threads for training) type: int32 default: 16 --num_sub_iterations (number of EM sub-iterations) type: int32 default: 2 --max_sentencepiece_length (maximum length of sentence piece) type: int32 default: 16 --max_sentence_length (maximum length of sentence in byte) type: int32 default: 4192 --split_by_unicode_script (use Unicode script to split sentence pieces) type: bool default: true --split_by_number (split tokens by numbers (0-9)) type: bool default: true --split_by_whitespace (use a white space to split sentence pieces) type: bool default: true --split_digits (split all digits (0-9) into separate pieces) type: bool default: false --treat_whitespace_as_suffix (treat whitespace marker as suffix instead of prefix.) type: bool default: false --allow_whitespace_only_pieces (allow pieces that only contain (consecutive) whitespace tokens) type: bool default: false --control_symbols (comma separated list of control symbols) type: std::string default: "" --control_symbols_file (load control_symbols from file.) type: std::string default: "" --user_defined_symbols (comma separated list of user defined symbols) type: std::string default: "" --user_defined_symbols_file (load user_defined_symbols from file.) type: std::string default: "" --required_chars (UTF8 characters in this flag are always used in the character set regardless of --character_coverage) type: std::string default: "" --required_chars_file (load required_chars from file.) type: std::string default: "" --byte_fallback (decompose unknown pieces into UTF-8 byte pieces) type: bool default: false --vocabulary_output_piece_score (Define score in vocab file) type: bool default: true --normalization_rule_name (Normalization rule name. Choose from nfkc or identity) type: std::string default: "nmt_nfkc" --normalization_rule_tsv (Normalization rule TSV file. ) type: std::string default: "" --denormalization_rule_tsv (Denormalization rule TSV file.) type: std::string default: "" --add_dummy_prefix (Add dummy whitespace at the beginning of text) type: bool default: true --remove_extra_whitespaces (Removes leading, trailing, and duplicate internal whitespace) type: bool default: true --hard_vocab_limit (If set to false, --vocab_size is considered as a soft limit.) type: bool default: true --use_all_vocab (If set to true, use all tokens as vocab. Valid for word/char models.) type: bool default: false --unk_id (Override UNK (<unk>) id.) type: int32 default: 0 --bos_id (Override BOS (<s>) id. Set -1 to disable BOS.) type: int32 default: 1 --eos_id (Override EOS (</s>) id. Set -1 to disable EOS.) type: int32 default: 2 --pad_id (Override PAD (<pad>) id. Set -1 to disable PAD.) type: int32 default: -1 --unk_piece (Override UNK (<unk>) piece.) type: std::string default: "<unk>" --bos_piece (Override BOS (<s>) piece.) type: std::string default: "<s>" --eos_piece (Override EOS (</s>) piece.) type: std::string default: "</s>" --pad_piece (Override PAD (<pad>) piece.) type: std::string default: "<pad>" --unk_surface (Dummy surface string for <unk>. In decoding <unk> is decoded to `unk_surface`.) type: std::string default: " ⁇ " --train_extremely_large_corpus (Increase bit depth for unigram tokenization.) type: bool default: false --random_seed (Seed value for random generator.) type: uint32 default: 4294967295 --enable_differential_privacy (Whether to add DP while training. Currently supported only by UNIGRAM model.) type: bool default: false --differential_privacy_noise_level (Amount of noise to add for DP) type: float default: 0 --differential_privacy_clipping_threshold (Threshold for clipping the counts for DP) type: std::uint64_t default: 0 --help (show help) type: bool default: false --version (show version) type: bool default: false --minloglevel (Messages logged at a lower level than this don't actually get logged anywhere) type: int default: 0
1.input
指定训练语料文件,支持两种格式.txt和.tsv(以制表符(Tab)作为分隔符的文件,类似于.csv文件),也可以传递以逗号分隔的文件列表。.txt文件内格式为每一行作为一个句子(sentences)。默认为""
--input "/path/botchan.txt"
--input ["/path/botchan1.txt", "path/botchan2.txt"]
一般大规模训练时,我们会有几十个文件,在一个文件夹下,这时候我们可以通过sh脚本:
files="/path/train_vocab/*" # 你的训练文件夹地址
file_list=$(echo $files | tr ' ' ',')
nohup spm_train --input $file_list
#...其他参数
2.input_format
指定输入文件的格式,支持的格式有两种:text对应.txt;tsv对应.tsv。默认为""
3.model_prefix
指定模型的输出前缀名,模型训练完成后,将使用这个前缀名来保存模型和词表文件。默认为""
4.model_type
指定模型的分词算法,支持的选项有 unigram、bpe、word和char。之前的文章已经介绍过这些分词算法,强烈建议看一下!默认为"unigram"
5.vocab_size
指定词表大小,默认为8000
6.accept_language
指定模型所支持的语言列表,多个语言可以用逗号分隔,语言代码是 ISO 639 标准定义的缩写,这个参数就是帮助模型识别语言,不设置也是可以的,默认为""
--accept_language "en,zh"
7.character_coverage
指定模型的字符覆盖率,较高的覆盖率可以使模型包含更多字符。对于字符集丰富的语言(如日语或中文)推荐的默认值为 0.9995,对于其他字符集较小的语言推荐默认值为 1.0。默认值为0.9995,如果词表比较大,或者说扩充的词表比较大,可以适当调大该参数。
8.input_sentence_size
指定训练过程中加载的训练句子的最大数量。如果设置为非0值,模型将只加载小于设定的训练句子数量,默认为0,不设置数量。
9.shuffle_input_sentence
当 --input_sentence_size 设置大于 0 时,此参数控制是否在加载输入句子之前对其进行随机采样,因为一般设置input_sentence_size时,是因为输入的句子太多了,比如我们输入的文件有1000个训练句子,但是我们只想要100个句子参与训练,这个时候设置这个参数就会随机采样100句。默认为True(但是input_sentence_size 设置大于 0 时才生效)。
10.seed_sentencepiece_size
指定用于种子子词单元的最大数量,默认为1000000
11.num_threads
指定在训练过程中使用的线程数,默认为16。这个要在解释一下,这个线程只有在 EM-step阶段使用即这个参数num_sub_iterations,其他阶段都是单线程。原作者的回复:“Muti-thread computation is used only in the EM-step, after the seed vocab generation phase with suffix array.”所以大部分时间你只能看到只有一个CPU达到了100%,其他CPU都没有利用,作者说会在将来实现。。
12.max_sentencepiece_length
指定子词单元的最大长度,默认为16。
13.max_sentence_length
指定输入句子的最大长度,是以字节为单位的,默认为4192,UTF-8中一个汉字3个字节,大概就是.txt一行最多1397个汉字。
14.split_by_unicode_script
指定是否用unicode脚本信息来划分子词单元,默认为True,解释一下Unicode 脚本,是 Unicode 标准中定义的一组字符集合,每个字符都被分配到一个或多个脚本(例如拉丁字母、希腊字母、汉字等)。当此参数启用时,模型在分割句子片段时会考虑每个字符所属的 Unicode 脚本,以便更好地处理不同脚本之间的边界,在多语言环境中非常有用。
15.split_by_number
指定是否用数字来划分子词单元,默认为False,就是划分子词的时候要不要用数字来划分。
16.split_by_whitespace
指定是否用空格来划分子词单元,默认为True
17.split_digits
指定是否将所有数字字符拆分为单独的单元,就是将”2023“拆成”2“,”0“,”2“,”3“这种独立的子词单元,好处是减少词表的数字量,所有数字都能表示,坏处是token数量会变多,一个”2023“就是4个token,默认是False,LLaMa是True
18.treat_whitespace_as_suffix
指定是否将空格字符作为子词的后缀而不是前缀,这里需要说明一下,空格也是基本字符,SentencePiece 使用元符号 “▁” (U+2581) 转义空格,这里的意思是空格放在前缀还是后缀,默认False
"say hi"
前缀:["say","_hi"]
后缀:["say_","hi"]
19.allow_whitespace_only_pieces
指定是否允许空格作为子词单元,就是单独的一个空格,默认为False。
20.control_symbols
指定一组控制符号,这些符号用于划分子词,方便词汇表的构建,默认为""。
21.control_symbols_file
指定包含一组控制符号的文件,这些符号用于划分子词,方便词汇表的构建,默认为""。
22.user_defined_symbols
用户可以定义一组符号,这些符号可能不在训练文本中出现,但需要用于划分子词,默认为""。
23.required_chars
指定一组 UTF-8 字符,它们将始终包含在生成的词汇表中,无论 --character_coverage参数的设置是多少,因为默认0.9995,并不会覆盖完全。
24.byte_fallback
这个参数是比较重要的,用于指定在遇到未知或很少的字符时将其分解为 UTF-8 字节来表示,这个参数打开了BPE实现的效果就和BBPE是一样的了(还是建议看一下上一篇文章),比如”魑魅魍魉“,假如在我们训练语料中出现的次数太少,我们最后的词表里没有这个词,如果不开启这个参数就会OOV,如果开启了,这个词就会被用UTF-8的编码来分词即:”0xE9 0xAD 0x91 0xE9 0xAD 0x85 0xE9 0xAD 0x8D 0xE9 0xAD 0x89“,就可以被分词,分成12个token。默认为False。
25.vocabulary_output_piece_score
是否将词汇表中的每个子词给出一个分数,默认为True。
26.normalization_rule_name
指定文本规范化的规则,可以选择 “nfkc” 或 “identity”。解释一下nfkc:是一种常见的文本规范化规则,它使用了 Unicode 规范化形式 NFKC (Normalization Form KC)。NFKC 规范化通过将字符进行规范化,去除字符的多种表示形式,来确保文本在比较和处理时保持一致。它会将一些特定的字符组合转换为等效的单一字符,例如将带有重音符号的字符转换为没有重音符号的字符,以便更容易进行搜索、排序和匹配。identity:不对文本进行任何规范化处理。如果选择这个规则,文本将按照原始输入的方式进行处理,不进行任何字符合并、替换或重排。默认为nfkc。
27.normalization_rule_tsv
允许从文件中加载自定义的文本规范化规则,默认为""
28.add_dummy_prefix
是否在文本的开头添加一个虚拟的空格标记,以帮助处理文本的开头,默认为True。
29.remove_extra_whitespaces
是否删除文本中的多余空格,包括开头、结尾和连续的内部空格,默认为True。
30.hard_vocab_limit
如果启用,–vocab_size 参数将被视为硬限制,词汇表的大小不会超过该值。如果禁用,词汇表大小可能会略微超过指定的值,默认为True。
31.use_all_vocab
如果启用,将使用子词作为词汇表,而不考虑 --vocab_size 参数的设置,默认为False。
32.接下来是一系列特殊id的解释就不做赘述了,unk_id,bos_id,eos_id,pad_id,unk_piece等等,在训练词表时,这些最好和原要扩充的词表相对应。
33.train_extremely_large_corpus
如果启用,将增加 unigram 分词算法中的比特深度,用于处理极大的训练语料库,只有在选用unigram 才生效,默认为False。
34.random_seed
随机种子,如果随机种子是一样的,训练结果是可以重复的
35.enable_differential_privacy
控制是否在训练过程中添加差分隐私设置,差分隐私:差分隐私用于防止模型过度依赖特定训练样本的信息,从而减少了对个体数据的敏感性。它通过在训练数据中引入噪音来实现这一点,使得模型不太可能准确地学习任何个别样本的细节信息。这有助于保护数据隐私,尤其是对于包含敏感信息的数据。仅在unigram 分词算法可设置。
上述就是一系列参数的解释,理解了这些参数后,再训练词表应该就会更游刃有余,我们以扩充LLaMa 中文词表为例开始进行一下简单实践:
准备一份中文训练语料保存为按照每一行保存为.txt文件,以BLOOM开源的中文维基百科数据前10000行为例:https://huggingface.co/datasets/bigscience-data/roots_zh-cn_wikipedia,下载后数据处理代码:
import pandas as pd
# 读取.parquet文件
parquet_file = '/path/file_name.parquet'
df = pd.read_parquet(parquet_file)
# 获取text列的前1万条数据,只用10000条来做测试
text_col = df['text'][:10000]
# 指定要写入的txt文件
txt_file = '/path/file_name.txt'
# 将数据追加写入txt文件
with open(txt_file, 'a') as file:
content_col.to_csv(file, sep='\t', index=False, header=False)
print(f'前1万条content数据已写入到 {txt_file}')
开始训练,这里面有几个参数要注意一下,第一个是model_type分词算法选择bpe,split_digits为True,byte_fallback为True,和LLaMa 保持一致,max_sentence_length设置的大一点:
nohup spm_train --input '/path/file_name.txt' \
--input_format text \
--model_prefix bpe_test \
--model_type bpe \
--vocab_size 10000 \
--character_coverage 0.9995 \
--num_threads 32 \
--split_digits True \
--byte_fallback True \
--max_sentence_length 24000 > bpe_test.log &
执行上述训练过程,大概需要90S左右,会在当前目录下生成三个文件,bpe_test.model,bpe_test.vocab,bpe_test.log。看一下模型的分词效果:
import sentencepiece as spm
sp_bpe = spm.SentencePieceProcessor()
sp_bpe.load('bpe_test.model')
print('*** BPE ***')
print(sp_bpe.encode_as_pieces('The excellence of a translation can only be judged by noting'))
print(len(sp_bpe.encode_as_pieces('The excellence of a translation can only be judged by noting')))
print(sp_bpe.encode_as_pieces('麒麟,是中国古代神话中的一种瑞兽'))
print(len(sp_bpe.encode_as_pieces('麒麟,是中国古代神话中的一种瑞兽')))
# 结果
*** BPE ***
['▁The', '▁', 'ex', 'c', 'ell', 'en', 'ce', '▁of', '▁a', '▁t', 'ran', 's', 'l', 'ation', '▁c', 'an', '▁', 'on', 'ly', '▁b', 'e', '▁', 'j', 'ud', 'g', 'ed', '▁b', 'y', '▁n', 'ot', 'ing']
31
['▁', '麒', '麟', ',', '是中国', '古代', '神', '话', '中', '的一种', '瑞', '兽']
可以看到,因为训练语料几乎都是中文的,对中文的分词效果是好于英文的,中文常见的一些词都变成了一个token,而英文被分的很碎。接下里把这个词表和原生LLaMa的词表进行合并。
直接看代码,参考代码见[3][4]:
import os os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"]="python" from transformers import LlamaTokenizer from sentencepiece import sentencepiece_model_pb2 as sp_pb2_model import sentencepiece as spm # 位置 llama_tokenizer_dir = "/path/llama-2-7b-hf" # 换成你自己模型的位置 chinese_sp_model_file ="/path/bpe_test.model" # 刚才训练的模型 # 加载 llama_tokenizer = LlamaTokenizer.from_pretrained(llama_tokenizer_dir) chinese_sp_model = spm.SentencePieceProcessor() chinese_sp_model.Load(chinese_sp_model_file) llama_spm = sp_pb2_model.ModelProto() llama_spm.ParseFromString(llama_tokenizer.sp_model.serialized_model_proto()) chinese_spm = sp_pb2_model.ModelProto() chinese_spm.ParseFromString(chinese_sp_model.serialized_model_proto()) # 打印两个词表的大小和原llama的特殊token print(len(llama_tokenizer),len(chinese_sp_model)) print(llama_tokenizer.all_special_tokens) print(llama_tokenizer.all_special_ids) print(llama_tokenizer.special_tokens_map) # 结果 32000 10000 ['<s>', '</s>', '<unk>'] [1, 2, 0] {'bos_token': '<s>', 'eos_token': '</s>', 'unk_token': '<unk>'} # 开始往llama词表里添加 llama_spm_tokens_set=set(p.piece for p in llama_spm.pieces) print(len(llama_spm_tokens_set)) print(f"Before:{len(llama_spm_tokens_set)}") for p in chinese_spm.pieces: piece = p.piece if piece not in llama_spm_tokens_set: new_p = sp_pb2_model.ModelProto().SentencePiece() new_p.piece = piece new_p.score = 0 llama_spm.pieces.append(new_p) print(f"New model pieces: {len(llama_spm.pieces)}") # 结果 32000 Before:32000 New model pieces: 40114 # 我们中文词表原来有1万,去重添加后,添加了8114个词。 # 保存合并后的模型 output_sp_dir = 'merged_tokenizer_sp_test' output_hf_dir = 'merged_tokenizer_hf_test' os.makedirs(output_sp_dir,exist_ok=True) with open(output_sp_dir+'/chinese_llama.model', 'wb') as f: f.write(llama_spm.SerializeToString()) tokenizer = LlamaTokenizer(vocab_file=output_sp_dir+'/chinese_llama.model') tokenizer.save_pretrained(output_hf_dir) print(f"Chinese-LLaMA tokenizer has been saved to {output_hf_dir}") # 看一下效果 llama_tokenizer = LlamaTokenizer.from_pretrained(llama_tokenizer_dir) chinese_llama_tokenizer = LlamaTokenizer.from_pretrained(output_hf_dir) text = "The excellence of a translation can only be judged by noting" print("Test text:\n",text) print(f"Tokenized by LLaMA tokenizer:{llama_tokenizer.tokenize(text)}") print(f"Tokenized length by LLaMA tokenizer:{len(llama_tokenizer.tokenize(text))}") print(f"Tokenized by chinese_llama tokenizer:{chinese_llama_tokenizer.tokenize(text)}") print(f"Tokenized length by LLaMA-extent-1 tokenizer:{len(chinese_llama_tokenizer.tokenize(text))}") #结果,可以看到在英文上是没有变化的 Test text: The excellence of a translation can only be judged by noting Tokenized by LLaMA tokenizer:['▁The', '▁excell', 'ence', '▁of', '▁a', '▁translation', '▁can', '▁only', '▁be', '▁jud', 'ged', '▁by', '▁not', 'ing'] Tokenized length by LLaMA tokenizer:14 Tokenized by chinese_llama tokenizer:['▁The', '▁excell', 'ence', '▁of', '▁a', '▁translation', '▁can', '▁only', '▁be', '▁jud', 'ged', '▁by', '▁not', 'ing'] Tokenized length by chinese_llama tokenizer:14 text = "麒麟,是中国古代神话中的一种瑞兽" print("Test text:\n",text) print(f"Tokenized by LLaMA tokenizer:{llama_tokenizer.tokenize(text)}") print(f"Tokenized length by LLaMA tokenizer:{len(llama_tokenizer.tokenize(text))}") print(f"Tokenized by chinese_llama tokenizer:{chinese_llama_tokenizer.tokenize(text)}") print(f"Tokenized length by chinese_llama tokenizer:{len(chinese_llama_tokenizer.tokenize(text))}") # 结果 Test text: 麒麟,是中国古代神话中的一种瑞兽 Tokenized by LLaMA tokenizer:['▁', '<0xE9>', '<0xBA>', '<0x92>', '<0xE9>', '<0xBA>', '<0x9F>', ',', '是', '中', '国', '古', '代', '神', '话', '中', '的', '一', '种', '<0xE7>', '<0x91>', '<0x9E>', '<0xE5>', '<0x85>', '<0xBD>'] Tokenized length by LLaMA tokenizer:25 Tokenized by chinese_llama tokenizer:['▁', '麒', '麟', ',', '是中国', '古代', '神', '话', '中的', '一种', '瑞', '兽'] Tokenized length by chinese_llama tokenizer:12
至此,我们完成了LLaMa中文词表的扩充,扩充垂直领域词表也是如此,要准备垂直领域的训练语料,最好和通用领域的训练语料混合一下。但是这些新增的词在模型的 embedding 如何初始化,以及后续的如何训练是更为重要的,下篇文章将介绍这部分的内容,欢迎关注~
参考
ab[1] https://github.com/google/sentencepiece
[2] https://zhuanlan.zhihu.com/p/636491955
[3] https://github.com/ymcui/Chinese-LLaMA-Alpaca/blob/main/scripts/merge_tokenizer/merge_tokenizers.py
[4]https://github.com/shibing624/MedicalGPT/blob/main/merge_tokenizers.py
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。