【easytokenizer】高性能文本 Tokenizer库 | 源码详解与编译安装
目录
■easytokenizer简介
■C++
1 编译
2 DEMO
3 速度
4 代码详解
(1)src/tokenizer.h
(2)src/tokenizer.cc
(3)examples/cpp/demo.cc
■Python
1 Requirements
2 Demo
3 Speed
■Links
■easytokenizer简介
easytokenizer-v0.2.0: 高性能文本 Tokenizer库
github地址:https://github.com/zejunwang1/easytokenizer
easytokenizer 是一个简单易用的高性能文本 Tokenizer 库,支持类似 HuggingFace transformers 中 BertTokenizer 的词语切分和标记化功能。具有如下特点:
▲实现高效,基于双数组字典树和 Unicode 规范化工具 utf8proc;
▲支持多线程,在处理大批量文本输入时有一定的加速效果;
▲支持 c++ 和 python。
提供了一个 Golang binding: https://github.com/sunhailin-Leo/easytokenizer-to-go。
■C++
1 编译
通过 cmake 进行编译:
git clone https://github.com/zejunwang1/easytokenizercd easytokenizer/mkdir buildcd build/# 默认使用 c++11 thread 线程库cmake ..# 使用 OMP 多线程# cmake -DWITH_OMP=ON .. make -j4
执行上述命令后,会在 build/examples/cpp 文件夹下生成可执行文件 demo。
2 DEMO
./examples/cpp/demo --vocab_path ../data/bert-base-chinese-vocab.txt --do_lower_case
运行后部分结果如下:
encode batch texts:
计算机科学与技术(Computer Science and Technology)是一门普通高等学校本科专业。
清华大学的[MASK]算机科学与技术专业实力全国第一。
encode result:
input_ids:
101 6369 5050 3322 4906 2110 680 2825 3318 8020 8134 11300 8196 9982 8256 11061 8021 3221 671 7305 3249 6858 7770 5023 2110 3413 3315 4906 683 689 511 102
101 3926 1290 1920 2110 4638 103 5050 3322 4906 2110 680 2825 3318 683 689 2141 1213 1059 1744 5018 671 511 102 0 0 0 0 0 0 0 0
attention_mask:
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0
offsets:
0 3 3 6 6 9 9 12 12 15 15 18 18 21 21 24 24 27 27 30 30 33 33 35 36 43 44 47 48 58 58 61 61 64 64 67 67 70 70 73 73 76 76 79 79 82 82 85 85 88 88 91 91 94 94 97 97 100 100 103
0 3 3 6 6 9 9 12 12 15 15 21 21 24 24 27 27 30 30 33 33 36 36 39 39 42 42 45 45 48 48 51 51 54 54 57 57 60 60 63 63 66 66 69
attention_mask 是用于指示模型哪些 token 是实际内容,哪些是填充内容(padding)的掩码数组。通常,1 表示该位置是有效的 token。0 表示该位置是填充的 token,模型在计算注意力时会忽略这些位置。
offsets 是一个数组,用于表示每个 token 在原始字符串中的起始和终止字节位置。每个 token 对应两个值:起始字节位置、终止字节位置。offsets 表示 input_ids 中除 [CLS] 和 [SEP] 外的其他有效 token 在原字符串中的字符/字节位置。
①当设置 add_cls_sep=true,codepoint_level=false 时,input_ids 中第 i 个 token 起始和终止字节位置为:
起始字节位置为 offsets[2 * (i - 1)]
终止字节位置为 offsets[2 * (i - 1) + 1];
在这种情况下,输入的 token 包含了特殊的 CLS 和 SEP token(通常是 BERT 等模型的输入格式)。假设 input_ids 中的 token 序列为:
[CLS] token1 token2 ... tokenN [SEP]
因为 CLS 是第一个 token(索引为 0),所以第 i 个 token 的索引为 i - 1。每个 token 在 offsets 中占据两个位置(起始和终止),因此需要乘以 2。
②当设置 add_cls_sep=false,codepoint_level=false 时,在这种情况下,输入的 token 不包含 CLS 和 SEP,直接是原始文本的 token 序列。input_ids 中第 i 个 token 在原字符串中的起始字节位置为 offsets[2 * i],终止字节位置为 offsets[2 * i + 1]。
综上,attention_mask:用于标记有效 token 和填充 token;
offsets:用于记录每个 token 在原始字符串中的起始和终止字节位置。
3 速度
在 sents.txt (10098 条句子) 上的测试结果如下:
batch_size | 1 | 32 | 64 | 128 | 512 | 1024 |
num_threads=1 | 0.349 | 0.344 | 0.347 | 0.342 | 0.349 | 0.357 |
num_threads=4 | — | 0.243 | 0.223 | 0.213 | 0.180 | 0.170 |
在 sents_17w.txt (179608 条句子) 上的测试结果如下:
batch_size | 1 | 32 | 64 | 128 | 512 | 1024 |
num_threads=1 | 2.241 | 2.558 | 2.532 | 2.507 | 2.431 | 2.443 |
num_threads=4 | — | 2.691 | 2.610 | 2.338 | 1.791 | 1.472 |
使用示例参考 example/cpp/demo.cc。
4 代码详解
(1)src/tokenizer.h
/*** Copyright (c) 2022-present, Zejun Wang (wangzejunscut@126.com)* All rights reserved.** This source code is licensed under the MIT license found in the* LICENSE file in the root directory of this source tree.*/#ifndef EASY_TOKENIZER_H
#define EASY_TOKENIZER_H#include <algorithm>
#include <cmath>
#include <numeric>
#include <thread>
#include <tuple>#include "dtrie.h"namespace tokenizer
{using WidthT = uint_fast8_t;
using Trie = cedar::DTrie;
using Token = std::tuple<int, int, std::string>;
using SizeT = size_t;// BasicTokenizer类用于执行基础的文本分词操作
class BasicTokenizer
{public:// 构造函数,允许设置是否进行小写处理BasicTokenizer(bool do_lower_case = true);// 对给定文本进行基础分词,返回一个Token对象的向量std::vector<Token> basic_tokenize(const std::string& text) const;// 对给定文本进行基础分词,将结果存储在传入的tokens向量中void basic_tokenize(const std::string& text, std::vector<Token>& tokens) const;// 从指定位置开始对给定文本进行分词,将结果存储在传入的tokens向量中void tokenize(const std::string& text, int pos, std::vector<Token>& tokens) const;protected:// 以下为特殊标记,用于在文本处理过程中标识特殊类型的Tokenconst std::string _pad_token = "[PAD]";const std::string _cls_token = "[CLS]";const std::string _sep_token = "[SEP]";const std::string _unk_token = "[UNK]";const std::string _mask_token = "[MASK]";// 标记是否执行小写处理bool _do_lower_case;// 最大前缀匹配数量,用于优化分词性能const int _max_prefix_matches = 64;// 使用Trie结构存储特殊标记,以提高查找效率std::unique_ptr<Trie> _special;// 标准化字符串,将输入的字符串转换为规范形式std::string normalize(const uint8_t* str) const;// 检查给定字符是否为控制字符int isCntrl(int c) const;
};// Tokenizer类继承自BasicTokenizer,用于文本的分词和编码
class Tokenizer : public BasicTokenizer
{public:// 构造函数,初始化分词器Tokenizer(const std::string& vocab_path, bool do_lower_case = true, bool codepoint_level = true);// 插入单个token到词汇表void insert(const std::string& token);// 插入多个tokens到词汇表void insert(const std::vector<std::string>& tokens);// 添加单个特殊tokenvoid add_special_tokens(const std::string& token);// 添加多个特殊tokensvoid add_special_tokens(const std::vector<std::string>& tokens);// 获取填充tokenstd::string pad_token() const;// 获取分类tokenstd::string cls_token() const;// 获取分割tokenstd::string sep_token() const;// 获取未知tokenstd::string unk_token() const;// 获取遮罩tokenstd::string mask_token() const;// 根据ID获取tokenstd::string get_token(int id) const;// 获取词汇表大小int size() const;// 获取填充token的IDint pad_id() const;// 获取分类token的IDint cls_id() const;// 获取分割token的IDint sep_id() const;// 获取未知token的IDint unk_id() const;// 获取MASK token的IDint mask_id() const;// 根据token获取IDint get_id(const std::string& token) const;// 检查token是否在词汇表中bool count(const std::string& token) const;// 将ID列表转换为tokensstd::vector<std::string> convert_ids_to_tokens(const std::vector<int>& input_ids) const;// 将tokens转换为ID列表std::vector<int> convert_tokens_to_ids(const std::vector<std::string>& tokens,bool add_cls_sep = false) const;// 将tokens转换为ID列表,结果存储在给定的vector中void convert_tokens_to_ids(const std::vector<std::string>& tokens,std::vector<int>& input_ids,bool add_cls_sep = false) const;// 使用wordpiece算法进行分词std::vector<std::string> wordpiece_tokenize(const std::string& text) const;// 使用wordpiece算法进行分词,并返回tokens和offsetsvoid wordpiece_tokenize(const std::string& text,std::vector<std::string>& tokens,std::vector<int>& offsets) const;// 编码单个句子std::vector<int> encode(const std::string& text,bool add_cls_sep = true,bool truncation = true,int max_length = 512) const;// 编码单个句子,并返回input_ids, attention_mask和offsetsvoid encode(const std::string& text,std::vector<int>& input_ids,std::vector<int>& attention_mask,std::vector<int>& offsets,bool add_cls_sep = true,bool truncation = true,int max_length = 512) const;// 编码多个句子void encode(const std::vector<std::string>& texts,std::vector<std::vector<int>>& input_ids,std::vector<std::vector<int>>& attention_mask,std::vector<std::vector<int>>& offsets,int num_threads = 1,bool add_cls_sep = true,bool padding = true,bool padding_to_max_length = false,bool truncation = true,int max_length = 512) const;protected:// 词汇表的Trie树表示std::unique_ptr<Trie> _vocab;// 是否在字符级别上处理代码点bool _codepoint_level = true;// 词汇表中特殊token的IDint _pad_id, _cls_id, _sep_id, _unk_id, _mask_id;// 每个词的最大输入字符数static const int _max_input_chars_per_word = 100;// 加载词汇表void load_vocab(const std::string& vocab_path);// 检查字符串是否由字母或数字组成bool isAlnum(const char* str, int len) const;// 构建字符到字节的映射void build_pos_map(const char* str, int len, std::vector<int>& pos_map) const;// 构建字节到字符的映射void build_index_map(const std::string& text, std::vector<int>& byte2index) const;// 获取NFD编码的代码点数量int NFD_codepoint_number(const uint8_t* str) const; // 获取token的代码点数量int get_codepoint_number(const std::string& token) const;// 获取字符串的代码点数量int get_codepoint_number(const char* str, int len) const;// 获取UTF-8字符的字节数WidthT get_num_bytes_of_utf8_char(const char* str, int len) const;// 在词汇表中搜索tokenint search(const char* str, int len, int index) const;
};}
#endif
(2)src/tokenizer.cc
/*** Copyright (c) 2022-present, Zejun Wang (wangzejunscut@126.com)* All rights reserved.** This source code is licensed under the MIT license found in the* LICENSE file in the root directory of this source tree.*/#include "tokenizer.h"
#include "utf8proc.h"namespace tokenizer
{// 构造函数: 初始化BasicTokenizer,并根据do_lower_case参数决定是否进行小写处理
BasicTokenizer::BasicTokenizer(bool do_lower_case) : _do_lower_case(do_lower_case)
{// 创建一个独特的Trie对象,用于后续的特殊词汇处理_special = std::unique_ptr<Trie>(new Trie());// 插入特殊词汇到Trie中,以便于在分词过程中快速识别和处理这些词汇_special->insert(_pad_token);_special->insert(_cls_token);_special->insert(_sep_token);_special->insert(_unk_token);_special->insert(_mask_token);
}/*** 将给定文本进行基础分词:将输入的文本字符串分解成一系列的Token(词元),供后续处理使用。* 它通过解析文本中的特殊字符或模式来确定如何分割文本,并将分割后的词元存储在tokens向量中。* * @param text 需要被分词的文本字符串* @param tokens 一个引用向量,用于存储分词后的Token对象*/
void BasicTokenizer::basic_tokenize(const std::string& text,std::vector<Token>& tokens) const
{// 如果tokens向量已有内容,清空以准备存储新一轮的分词结果if (tokens.size())tokens.clear();// 预先分配足够的空间以提高性能tokens.reserve(text.size());// 使用特殊字符或模式解析文本,返回匹配结果auto matches = _special->parse(text, _max_prefix_matches);// 如果没有找到任何特殊字符或模式,则直接对整个文本进行分词if (matches.empty()){tokenize(text, 0, tokens);return;}// 初始化起始位置int start = 0;std::string subtext;// 遍历所有匹配的特殊字符或模式for (int i = 0; i < matches.size(); i++){// 提取当前匹配前的子字符串subtext = text.substr(start, matches[i].first - start);// 如果子字符串非空,则对其进行分词if (subtext.size()){tokenize(subtext, start, tokens);start += subtext.size();}// 创建并添加特殊Token到tokens向量中tokens.emplace_back(start, start + matches[i].second.size(), matches[i].second);// 更新起始位置以跳过已处理的部分start += matches[i].second.size();}// 检查是否还有剩余未处理的文本if (start < text.size()){// 对剩余文本进行分词subtext = text.substr(start);tokenize(subtext, start, tokens);}
}std::vector<Token>
BasicTokenizer::basic_tokenize(const std::string& text) const
{std::vector<Token> tokens;basic_tokenize(text, tokens);return tokens;
}void BasicTokenizer::tokenize(const std::string& text, int pos,std::vector<Token>& tokens) const
{int32_t unicode = 0;bool last_state = false;auto data = text.c_str();int i = 0, m = 0, n = 0, start = 0, len = text.size();char* word = new char[len + 1];uint8_t* ch = new uint8_t[8];while (i < len){if (isascii(data[i])) {if (isalnum(data[i])) {word[n++] = (_do_lower_case) ? std::tolower(data[i++]) : data[i++];continue;}if (n == 0) {word[n++] = data[i++];word[n] = '\0';start = i - n;if (!isCntrl(word[0])) {last_state = false;if (!isspace(word[0])) tokens.emplace_back(pos + start, pos + i, std::string(word, n));}n = 0;}if (n > 0) {word[n] = '\0';start = i - n;if (!last_state)tokens.emplace_back(pos + start, pos + i, std::string(word, n));else {std::get<1>(tokens.back()) = pos + i;std::get<2>(tokens.back()).append(word, n);}n = 0;last_state = true;}}else{if (n > 0) {word[n] = '\0';start = i - n;if (!last_state)tokens.emplace_back(pos + start, pos + i, std::string(word, n));else{std::get<1>(tokens.back()) = pos + i;std::get<2>(tokens.back()).append(word, n);}n = 0;last_state = true;}word[n++] = data[i++];while (i < len && (data[i] & 0xC0) == 0x80)word[n++] = data[i++];word[n] = '\0';start = i - n;// unicode valueutf8proc_iterate((uint8_t*)word, n, &unicode);// is chinese characterif ((unicode >= 0x4E00 && unicode <= 0x9FFF) ||(unicode >= 0x3400 && unicode <= 0x4DBF) ||(unicode >= 0x20000 && unicode <= 0x2A6DF) ||(unicode >= 0x2A700 && unicode <= 0x2B73F) ||(unicode >= 0x2B740 && unicode <= 0x2B81F) ||(unicode >= 0x2B820 && unicode <= 0x2CEAF) ||(unicode >= 0xF900 && unicode <= 0xFAFF) ||(unicode >= 0x2F800 && unicode <= 0x2FA1F) ||(utf8proc_category_string(unicode)[0] == 'P')) {tokens.emplace_back(pos + start, pos + i, std::string(word, n));last_state = false;} else if (strcmp(utf8proc_category_string(unicode), "Zs") == 0)last_state = false;else if ((unicode == 0xFFFD) || (utf8proc_category_string(unicode)[0] == 'C'))n = 0;else{if (_do_lower_case) {m = utf8proc_encode_char(utf8proc_tolower(unicode), ch);ch[m] = '\0';auto token = normalize(ch);if (token.size() == 1 && !isalnum(token[0]) && !isCntrl(token[0])){last_state = false;if (!isspace(token[0]))tokens.emplace_back(pos + start, pos + i, token);n = 0;continue;}if (!last_state)tokens.emplace_back(pos + start, pos + i, token);else{std::get<1>(tokens.back()) = pos + i;std::get<2>(tokens.back()).append(token);}}else{if (!last_state)tokens.emplace_back(pos + start, pos + i, std::string(word, n));else{std::get<1>(tokens.back()) = pos + i;std::get<2>(tokens.back()).append(word, n);}}last_state = true;}n = 0;}}if (n > 0) {word[n] = '\0';start = i - n;if (!last_state)tokens.emplace_back(pos + start, pos + i, word);else{std::get<1>(tokens.back()) = pos + i;std::get<2>(tokens.back()).append(word, n);}}delete []ch;delete []word;
}std::string BasicTokenizer::normalize(const uint8_t* str) const
{auto norm = utf8proc_NFD(str);int len = strlen((char*)norm);std::string result;result.reserve(len);int32_t unicode = 0;int i = 0, n = 0;while (i < len){if (isascii(norm[i])){result.push_back(norm[i]);i++;}else{i++;n++;while (i < len && (norm[i] & 0xC0) == 0x80){i++;n++;}utf8proc_iterate(norm + i - n, n, &unicode);if (strcmp(utf8proc_category_string(unicode), "Mn") != 0)result.append((char*)(norm + i - n), n);n = 0;}}if (norm) {free(norm);norm = NULL;}return result;
}int BasicTokenizer::isCntrl(int c) const
{if (c == '\t' || c == '\r' || c == '\n')return 0;return iscntrl(c);
}Tokenizer::Tokenizer(const std::string& vocab_path, bool do_lower_case, bool codepoint_level)
: BasicTokenizer(do_lower_case), _codepoint_level(codepoint_level)
{_vocab = std::unique_ptr<Trie>(new Trie());load_vocab(vocab_path);_pad_id = _vocab->get_index(_pad_token);_cls_id = _vocab->get_index(_cls_token);_sep_id = _vocab->get_index(_sep_token);_unk_id = _vocab->get_index(_unk_token);_mask_id = _vocab->get_index(_mask_token);
}/*** 加载词汇表* * 本函数从指定的文件路径中读取词汇表,并将其加载到_tokenizer对象中* 词汇表中的每个词汇将被插入到_tokenizer的_vocab集合中,以便后续的文本处理* * @param vocab_path 词汇表文件的路径* * @throws std::invalid_argument 如果无法打开指定路径的文件,则抛出异常*/
void Tokenizer::load_vocab(const std::string& vocab_path)
{// 尝试打开词汇表文件std::ifstream ifs(vocab_path);// 如果文件无法打开,则抛出异常if (!ifs.is_open())throw std::invalid_argument(vocab_path + " can not be opened for loading!");// 逐行读取文件中的词汇std::string token;while (std::getline(ifs, token))// 忽略空行,并将非空的词汇插入到词汇表中if (!token.empty())_vocab->insert(token);// 关闭文件流ifs.close();
}bool Tokenizer::isAlnum(const char* str, int len) const
{for (int i = 0; i < len; i++)if (!isalnum(str[i]))return false;return true;
}void Tokenizer::build_pos_map(const char* str, int len, std::vector<int>& pos_map) const
{int32_t unicode = 0;uint8_t* ch = new uint8_t[8];int cur = 0, val = 0, m = 0, n = 0;while (cur < len){if (isascii(str[cur])){if (isCntrl(str[cur])){val++;cur++;continue;}pos_map.emplace_back(val);val++;cur++;}else{n++;cur++;while (cur < len && (str[cur] & 0xC0) == 0x80){n++;cur++;}utf8proc_iterate((const uint8_t*)(str + cur - n), n, &unicode);if ((unicode == 0xFFFD) || (utf8proc_category_string(unicode)[0] == 'C')){val++;n = 0;continue; }if (_do_lower_case){m = utf8proc_encode_char(utf8proc_tolower(unicode), ch);ch[m] = '\0';for (int i = 0; i < NFD_codepoint_number(ch); i++)pos_map.emplace_back(val);}elsepos_map.emplace_back(val);val++;n = 0;}}pos_map.emplace_back(val);delete []ch;
}int Tokenizer::NFD_codepoint_number(const uint8_t* str) const
{auto norm = utf8proc_NFD(str);int len = strlen((char*)norm);int32_t unicode = 0;int i = 0, n = 0, c = 0;while (i < len){if (isascii(norm[i])){i++;c++;}else{i++;n++;while (i < len && (norm[i] & 0xC0) == 0x80){i++;n++;}utf8proc_iterate(norm + i - n, n, &unicode);if (strcmp(utf8proc_category_string(unicode), "Mn") != 0)c++;n = 0;}}if (norm) {free(norm);norm = NULL;}return c;
}WidthT Tokenizer::get_num_bytes_of_utf8_char(const char* str, int len) const
{int cur = 1;WidthT num_bytes = 1;while (cur < len && (str[cur++] & 0xC0) == 0x80)num_bytes++;return num_bytes;
}int Tokenizer::get_codepoint_number(const char* str, int len) const
{int cur_bytes = 0, cur_index = 0;while (cur_bytes < len) {cur_bytes += get_num_bytes_of_utf8_char(str + cur_bytes, len - cur_bytes);cur_index ++;}return cur_index;
}int Tokenizer::get_codepoint_number(const std::string& token) const
{ return get_codepoint_number(token.data(), token.size()); }void Tokenizer::build_index_map(const std::string& text, std::vector<int>& byte2index) const
{auto data = text.c_str();int cur_bytes = 0, cur_index = 0, len = text.size();byte2index.resize(len + 1, -1);while (cur_bytes < len){byte2index[cur_bytes] = cur_index++;cur_bytes += get_num_bytes_of_utf8_char(data + cur_bytes, len - cur_bytes);}byte2index[cur_bytes] = cur_index;
}int Tokenizer::search(const char* str, int len, int index) const
{int cur_bytes = 0, cur_index = 0;while (cur_bytes < len){if (cur_index == index)return cur_bytes;cur_bytes += get_num_bytes_of_utf8_char(str + cur_bytes, len - cur_bytes);cur_index ++;}if (cur_index == index)return len;return -1;
}void Tokenizer::insert(const std::string& token)
{ _vocab->insert(token); }void Tokenizer::insert(const std::vector<std::string>& tokens)
{ _vocab->insert(tokens); }void Tokenizer::add_special_tokens(const std::string& token)
{ _special->insert(token); }void Tokenizer::add_special_tokens(const std::vector<std::string>& tokens)
{ _special->insert(tokens); }std::string Tokenizer::pad_token() const
{ return _pad_token; }std::string Tokenizer::cls_token() const
{ return _cls_token; }std::string Tokenizer::sep_token() const
{ return _sep_token; }std::string Tokenizer::unk_token() const
{ return _unk_token; }std::string Tokenizer::mask_token() const
{ return _mask_token; }std::string Tokenizer::get_token(int id) const
{ return _vocab->get_key(id); }int Tokenizer::size() const
{ return _vocab->size(); }int Tokenizer::pad_id() const
{ return _pad_id; }int Tokenizer::cls_id() const
{ return _cls_id; }int Tokenizer::sep_id() const
{ return _sep_id; }int Tokenizer::unk_id() const
{ return _unk_id; }int Tokenizer::mask_id() const
{ return _mask_id; }int Tokenizer::get_id(const std::string& token) const
{int id = _vocab->get_index(token);return id < 0 ? _unk_id : id;
}bool Tokenizer::count(const std::string& token) const
{ return _vocab->count(token); }std::vector<std::string>
Tokenizer::convert_ids_to_tokens(const std::vector<int>& input_ids) const
{std::vector<std::string> tokens;tokens.reserve(input_ids.size());for (int i = 0; i < input_ids.size(); i++)tokens.emplace_back(get_token(input_ids[i]));return tokens;
}std::vector<int> Tokenizer::convert_tokens_to_ids(const std::vector<std::string>& tokens, bool add_cls_sep) const
{std::vector<int> input_ids;input_ids.reserve(tokens.size() + 2);convert_tokens_to_ids(tokens, input_ids, add_cls_sep);return input_ids;
}void Tokenizer::convert_tokens_to_ids(const std::vector<std::string>& tokens,std::vector<int>& input_ids,bool add_cls_sep) const
{if (add_cls_sep)input_ids.emplace_back(_cls_id);for (int i = 0; i < tokens.size(); i++)input_ids.emplace_back(get_id(tokens[i]));if (add_cls_sep)input_ids.emplace_back(_sep_id);
}/*** 使用WordPiece算法对文本进行分词* * @param text 输入的文本字符串* @param tokens 输出的分词结果,每个元素是一个分词后的字符串* @param offsets 输出的每个分词结果在原始文本中的起始和结束位置偏移量* * 该函数首先清除tokens和offsets中的内容,然后根据基本分词规则将文本分割成基础token;* 对于每个基础token,如果它是一个特殊词汇或已经在词汇表中,则直接添加到结果中;* 否则,尝试使用WordPiece算法进一步分割token;* 如果token太大以至于超过了最大输入字符限制,则将其标记为未知词汇;* 对于那些可以被进一步分割的token,将其拆分成更小的子token,并记录它们的偏移量;* 如果在分割过程中遇到无法分割的情况,则将整个token标记为未知词汇;* 最后,根据是否需要按字节索引映射偏移量,分别处理并记录每个子token的偏移量。*/
void Tokenizer::wordpiece_tokenize(const std::string& text,std::vector<std::string>& tokens,std::vector<int>& offsets) const
{// 清除tokens和offsets中的内容,为新的分词结果做准备if (tokens.size())tokens.clear();if (offsets.size())offsets.clear();// 预留足够的空间以提高性能tokens.reserve(text.size());offsets.reserve(2 * text.size());// 使用基本分词规则将文本分割成基础tokenstd::vector<Token> base_tokens;basic_tokenize(text, base_tokens);// 如果需要按字节索引映射偏移量,则构建字节到索引的映射表std::vector<int> byte2index;if (_codepoint_level)build_index_map(text, byte2index);// 初始化标志变量和迭代器bool is_bad = false;auto data = text.c_str();int start = 0, end = 0, cur = 0, pos = 0, len = 0, num = 0;std::string token, prefix, subtoken;std::vector<int> pos_map;std::vector<Token> sub_tokens;pos_map.reserve(_max_input_chars_per_word);sub_tokens.reserve(_max_input_chars_per_word);// 遍历每个基础tokenfor (int i = 0; i < base_tokens.size(); i++) {start = std::get<0>(base_tokens[i]);end = std::get<1>(base_tokens[i]);token = std::get<2>(base_tokens[i]);// 如果token是一个特殊词汇或已经在词汇表中,则直接添加到结果中if (_special->count(token) || _vocab->count(token)) {tokens.emplace_back(token);// 根据是否需要按字节索引映射偏移量,分别处理并记录偏移量if (_codepoint_level){offsets.emplace_back(byte2index[start]);offsets.emplace_back(byte2index[end]);}else{offsets.emplace_back(start);offsets.emplace_back(end);}continue;}// 如果token太大以至于超过了最大输入字符限制,则将其标记为未知词汇if (token.size() > _max_input_chars_per_word){tokens.emplace_back(_unk_token);// 根据是否需要按字节索引映射偏移量,分别处理并记录偏移量if (_codepoint_level){offsets.emplace_back(byte2index[start]);offsets.emplace_back(byte2index[end]);}else{offsets.emplace_back(start);offsets.emplace_back(end);}continue;}// 使用WordPiece算法进一步分割tokencur = 0;pos = 0;is_bad = false;sub_tokens.clear();while (cur < token.size()) {subtoken = token.substr(cur);if (cur > 0) subtoken = "##" + subtoken;prefix = _vocab->max_prefix(subtoken, _max_prefix_matches);// 如果无法找到有效的前缀,则将整个token标记为未知词汇if ((cur > 0 && prefix.size() < 3) || prefix.empty()) {is_bad = true;break;}// 计算子token的长度和数量len = cur > 0 ? (prefix.size() - 2) : prefix.size();num = cur > 0 ? get_codepoint_number(prefix) - 2 :get_codepoint_number(prefix);// 将子token添加到结果中sub_tokens.emplace_back(pos, pos + num, prefix);cur += len;pos += num;}// 如果token无法被有效分割,则将其标记为未知词汇if (is_bad) {tokens.emplace_back(_unk_token);// 根据是否需要按字节索引映射偏移量,分别处理并记录偏移量if (_codepoint_level){offsets.emplace_back(byte2index[start]);offsets.emplace_back(byte2index[end]);}else{offsets.emplace_back(start);offsets.emplace_back(end);}continue;}// 如果token是字母或数字,则直接添加到结果中if (isAlnum(data + start, end - start)){for (int j = 0; j < sub_tokens.size(); j++){tokens.emplace_back(std::get<2>(sub_tokens[j]));// 根据是否需要按字节索引映射偏移量,分别处理并记录偏移量if (_codepoint_level){offsets.emplace_back(byte2index[start] + std::get<0>(sub_tokens[j]));offsets.emplace_back(byte2index[start] + std::get<1>(sub_tokens[j]));}else{offsets.emplace_back(start + std::get<0>(sub_tokens[j]));offsets.emplace_back(start + std::get<1>(sub_tokens[j]));}}continue;}// 对于非字母或数字的token,构建位置映射表并添加到结果中pos_map.clear();build_pos_map(data + start, end - start, pos_map);for (int j = 0; j < sub_tokens.size(); j++){auto a = std::get<0>(sub_tokens[j]);auto b = std::get<1>(sub_tokens[j]);b = (pos_map[a] == pos_map[b]) ? (pos_map[a] + 1) : pos_map[b];a = pos_map[a];tokens.emplace_back(std::get<2>(sub_tokens[j]));// 根据是否需要按字节索引映射偏移量,分别处理并记录偏移量if (_codepoint_level){offsets.emplace_back(byte2index[start] + a);offsets.emplace_back(byte2index[start] + b);}else{offsets.emplace_back(start + search(data + start, end - start, a));offsets.emplace_back(start + search(data + start, end - start, b));}}}
}std::vector<std::string>
Tokenizer::wordpiece_tokenize(const std::string& text) const
{std::vector<std::string> tokens;std::vector<int> offsets;wordpiece_tokenize(text, tokens, offsets);return tokens;
}/*** 将给定文本编码为模型可处理的格式。* * 该函数对输入文本进行分词和编码,生成模型所需的输入ID序列、注意力掩码和偏移量信息。* 它支持添加特殊标记(如CLS和SEP)、截断过长的序列以及根据最大长度进行填充。* * @param text 输入文本,将被编码。* @param input_ids 存储编码后的输入ID序列的向量。* @param attention_mask 存储注意力掩码的向量,用于区分有效和填充部分。* @param offsets 存储每个编码单元的偏移量信息,用于追踪原始文本中的位置。* @param add_cls_sep 是否在序列两端添加CLS和SEP标记。* @param truncation 是否启用截断策略,以适应最大长度限制。* @param max_length 序列的最大长度,包括特殊标记。*/
void Tokenizer::encode(const std::string& text,std::vector<int>& input_ids,std::vector<int>& attention_mask,std::vector<int>& offsets,bool add_cls_sep,bool truncation,int max_length) const
{// 清空输入向量,准备编码if (input_ids.size())input_ids.clear();if (attention_mask.size())attention_mask.clear();if (offsets.size())offsets.clear();// 将文本分词为token,同时计算偏移量std::vector<std::string> tokens;wordpiece_tokenize(text, tokens, offsets);// 生成input_idsint n = tokens.size();int capacity = std::max(max_length, n + 2);input_ids.reserve(capacity);convert_tokens_to_ids(tokens, input_ids, add_cls_sep);// 执行截断操作,确保序列不超过最大长度if (truncation && input_ids.size() > max_length){n = 2 * max_length;if (add_cls_sep)n -= 4;input_ids.resize(max_length);offsets.resize(n);if (add_cls_sep)input_ids[max_length - 1] = _sep_id;if (input_ids.capacity() > max_length * 4)std::vector<int>(input_ids).swap(input_ids);} // 生成attention_maskn = input_ids.size();capacity = std::max(max_length, n);attention_mask.reserve(capacity);attention_mask.resize(n, 1);
}/*** 将给定文本编码为令牌(tokens)的ID序列。* * 该函数首先将输入文本分词,然后将这些分词转换为对应的ID,根据参数决定是否添加CLS和SEP标记,* 并且可以根据max_length参数和truncation标志来调整输出序列的长度。* * @param text 输入文本字符串。* @param add_cls_sep 是否在序列两端添加CLS和SEP标记。* @param truncation 是否启用截断策略,以适应max_length。* @param max_length 期望的最大序列长度。* @return 编码后的令牌ID序列。*/
std::vector<int> Tokenizer::encode(const std::string& text, bool add_cls_sep, bool truncation, int max_length) const
{// 将输入文本进行分词处理auto tokens = std::move(wordpiece_tokenize(text));// 计算所需容量,确保能够容纳所有令牌ID,包括可能添加的CLS和SEP标记int capacity = std::max(max_length, int(tokens.size() + 2));// 初始化令牌ID序列的容器std::vector<int> input_ids;input_ids.reserve(capacity);// 将分词转换为对应的IDconvert_tokens_to_ids(tokens, input_ids, add_cls_sep);// truncationif (truncation && input_ids.size() > max_length){// 如果启用了截断策略且序列超长,则调整序列长度input_ids.resize(max_length);// 如果添加了CLS和SEP标记,确保末尾是SEP的IDif (add_cls_sep)input_ids[max_length - 1] = _sep_id;// 如果容器容量过大,创建一个新的较小的向量来替换当前的,以优化内存使用if (input_ids.capacity() > max_length * 4)std::vector<int>(input_ids).swap(input_ids);}// 返回编码后的令牌ID序列return input_ids;
}/*** 对一批文本进行编码,生成对应的输入ID、注意力掩码和偏移量。* * 该函数处理一批输入文本,将其转换为模型可接受的统一格式。* 支持多线程处理以提高效率,并提供添加特殊标记、填充和截断等选项,* 以确保输入符合模型的要求。* * @param texts 输入文本列表。* @param input_ids 输出参数,存储编码后的输入ID序列。* @param attention_mask 输出参数,存储注意力掩码序列。* @param offsets 输出参数,存储偏移量序列。* @param num_threads 使用的线程数。若大于1,则启用多线程。* @param add_cls_sep 是否在文本开头和结尾添加特殊标记(CLS和SEP)。* @param padding 是否对序列进行填充。* @param padding_to_max_length 是否将所有序列填充到指定的最大长度。* @param truncation 是否对超过最大长度的序列进行截断。* @param max_length 序列的最大长度。*/
void Tokenizer::encode(const std::vector<std::string>& texts,std::vector<std::vector<int>>& input_ids,std::vector<std::vector<int>>& attention_mask,std::vector<std::vector<int>>& offsets,int num_threads,bool add_cls_sep,bool padding,bool padding_to_max_length,bool truncation,int max_length) const
{// 清除input_ids、attention_mask和offsets中已有的数据。if (input_ids.size())input_ids.clear();if (attention_mask.size())attention_mask.clear();if (offsets.size())offsets.clear();// 初始化输入数据的大小。int n = texts.size();input_ids.resize(n);attention_mask.resize(n);offsets.resize(n);// 根据num_threads决定使用单线程还是多线程进行编码。if (num_threads <= 1)for (int i = 0; i < n; i++)encode(texts[i], input_ids[i], attention_mask[i], offsets[i],add_cls_sep, truncation, max_length);else{// 多线程实现。#ifdef WITH_OMP#pragma omp parallel for num_threads(num_threads)for (int i = 0; i < n; i++)encode(texts[i], input_ids[i], attention_mask[i], offsets[i], add_cls_sep, truncation, max_length);#elsestd::vector<std::thread> threads;threads.reserve(static_cast<size_t>(num_threads));auto func = [&](int start_index, int end_index){for (int i = start_index; i < end_index; i++)encode(texts[i], input_ids[i], attention_mask[i], offsets[i], add_cls_sep, truncation, max_length);};int start = 0, end = 0, step = ceil(n / float(num_threads));for (int i = 0; i < num_threads; i++){end = start + step;if (end > n)end = n;threads.emplace_back(std::thread(func, start, end));start = end;}for (auto& t : threads)t.join();#endif}// 如果不需要填充,则直接返回。if (!padding)return;// 填充处理。// 查找当前批次中最长的序列长度。int max_seq_len = std::accumulate(input_ids.begin(), input_ids.end(), 0,[](size_t len, const std::vector<int>& input){ return std::max(len, input.size()); });int seq_len = padding_to_max_length ? max_length : max_seq_len;for (int i = 0; i < n; i++){input_ids[i].resize(seq_len, _pad_id);attention_mask[i].resize(seq_len);}
}}
(3)examples/cpp/demo.cc
#include <iostream>
#include <string>
#include <vector>#include "args.h"
#include "tokenizer.h"// 主函数是程序的入口点
int main(int argc, char* argv[]) {// 创建一个参数解析器对象,用于解析命令行参数args::ArgumentParser parser("easytokenizer-cpp 使用演示");// 定义一个帮助标志,当存在 -h 或 --help 选项时显示帮助信息args::HelpFlag help(parser, "help", "显示帮助信息", {'h', "help"});// 定义一个值标志,用于指定分词器词汇文件的路径args::ValueFlag<std::string> vocabPath(parser, "", "分词器词汇文件的路径", {"vocab_path"});// 定义一个标志,指示是否将大写字母转换为小写args::Flag doLowerCase(parser, "", "是否将大写字母转换为小写", {"do_lower_case"});// 定义一个标志,指示是否在偏移中返回字符位置args::Flag codepointLevel(parser, "", "是否在偏移中返回字符位置", {"codepoint_level"});// 解析参数try{ parser.ParseCLI(argc, argv);}catch (args::Help) {// 如果触发帮助标志,则输出帮助信息并退出程序std::cerr << parser;return 0;} catch (args::ParseError e) {// 如果解析参数时出错,则输出错误信息和帮助信息,然后退出程序std::cerr << e.what() << std::endl;std::cerr << parser;std::exit(EXIT_FAILURE);} catch (args::ValidationError e) {// 如果参数验证失败,则输出错误信息和帮助信息,然后退出程序std::cerr << e.what() << std::endl;std::cerr << parser;std::exit(EXIT_FAILURE);}// 初始化变量以存储命令行参数的值std::string vocab_path;bool do_lower_case = false;bool codepoint_level = false;// 检索并分配命令行参数的值if (vocabPath)vocab_path = args::get(vocabPath);if (doLowerCase)do_lower_case = true;if (codepointLevel)codepoint_level = true;// 检查词汇文件路径是否为空,如果是,则输出帮助信息并抛出异常if (vocab_path.empty()){std::cerr << parser;throw std::invalid_argument("词汇文件路径为空!");}// 使用给定的参数初始化分词器对象tokenizer::Tokenizer AutoTokenizer(vocab_path, do_lower_case, codepoint_level);/************************************ 对单个文本进行编码**********************************/// 要分词的文本std::string text = "计算机科学与技术(Computer Science and Technology)是一门普通高等学校本科专业。";// 输出要分词的文本std::cout << "对单个文本进行编码:" << std::endl;std::cout << text << std::endl;// 分词std::vector<std::string> tokens;std::vector<int> offsets;AutoTokenizer.wordpiece_tokenize(text, tokens, offsets);std::cout << "分词结果:" << std::endl;for (size_t i = 0; i < tokens.size(); i++) {std::cout << tokens[i] << " 开始:" << offsets[2 * i] << " 结束:" << offsets[2 * i + 1];std::cout << std::endl;}bool add_cls_sep = true;bool truncation = true;int max_length = 512;// 编码文本std::vector<int> input_ids;std::vector<int> attention_mask;AutoTokenizer.encode(text, input_ids, attention_mask, offsets, add_cls_sep, truncation, max_length);std::cout << "编码结果:" << std::endl;for (size_t i = 0; i < input_ids.size(); i++)std::cout << input_ids[i] << " ";std::cout << std::endl << std::endl;/************************************ 批量编码文本**********************************/std::vector<std::string> texts;texts.emplace_back("计算机科学与技术(Computer Science and Technology)是一门普通高等学校本科专业。");texts.emplace_back("清华大学的[MASK]算机科学与技术专业实力全国第一。");std::cout << "批量编码文本:" << std::endl;for (size_t i = 0; i < texts.size(); i++)std::cout << texts[i] << std::endl;int num_threads = 1;bool padding = true;bool padding_to_max_length = false;std::vector<std::vector<int>> batch_input_ids;std::vector<std::vector<int>> batch_attention_mask;std::vector<std::vector<int>> batch_offsets;AutoTokenizer.encode(texts, batch_input_ids, batch_attention_mask, batch_offsets, num_threads, add_cls_sep, padding, padding_to_max_length, truncation, max_length);std::cout << "编码结果:" << std::endl;std::cout << "input_ids:" << std::endl;for (size_t i = 0; i < batch_input_ids.size(); i++) {for (size_t j = 0; j < batch_input_ids[i].size(); j++)std::cout << batch_input_ids[i][j] << " ";std::cout << std::endl;}std::cout << "attention_mask:" << std::endl;for (size_t i = 0; i < batch_attention_mask.size(); i++){for (size_t j = 0; j < batch_attention_mask[i].size(); j++)std::cout << batch_attention_mask[i][j] << " ";std::cout << std::endl;}std::cout << "offsets:" << std::endl;for (size_t i = 0; i < batch_offsets.size(); i++){for (size_t j = 0; j < batch_offsets[i].size(); j++)std::cout << batch_offsets[i][j] << " ";std::cout << std::endl;}return 0;
}
■Python
1 Requirements
Python version >= 3.6
pybind11 >= 2.2
setuptools >= 0.7.0
从 github 仓库安装最新版本:
pip install git+https://github.com/zejunwang1/easytokenizer
或者:
git clone https://github.com/zejunwang1/easytokenizercd easytokenizer/python setup.py install
2 Demo
示例位于 example/python/demo.py
# coding=utf-8from easytokenizer import AutoTokenizervocab_path = "../../data/bert-base-chinese-vocab.txt"
tokenizer = AutoTokenizer(vocab_path, do_lower_case = True)# encode batch texts
texts = ["计算机科学与技术(Computer Science and Technology)是一门普通高等学校本科专业。","清华大学的[MASK]算机科学与技术专业实力全国第一。"]
result = tokenizer.encode(texts, num_threads = 1, add_cls_sep = True, padding = True, padding_to_max_length = False,truncation = True, max_length = 512)
print("encode batch texts:")
print("input_ids:")
print(result["input_ids"])
print("attention_mask:")
print(result["attention_mask"])
print("offsets:")
print(result["offsets"])
运行后结果如下:
encode batch texts:
input_ids:
[[101, 6369, 5050, 3322, 4906, 2110, 680, 2825, 3318, 8020, 8134, 11300, 8196, 9982, 8256, 11061, 8021, 3221, 671, 7305, 3249, 6858, 7770, 5023, 2110, 3413, 3315, 4906, 683, 689, 511, 102], [101, 3926, 1290, 1920, 2110, 4638, 103, 5050, 3322, 4906, 2110, 680, 2825, 3318, 683, 689, 2141, 1213, 1059, 1744, 5018, 671, 511, 102, 0, 0, 0, 0, 0, 0, 0, 0]]
attention_mask:
[[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]]
offsets:
[[0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 12, 12, 15, 15, 17, 18, 25, 26, 29, 30, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 49, 49, 50, 50, 51, 51, 52, 52, 53, 53, 54, 54, 55], [0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23, 24, 24, 25, 25, 26, 26, 27]]
3 Speed
运行 python_testing/test_speed.py 进行速度测试:
python test_speed.py --vocab_path ../data/bert-base-chinese-vocab.txt --data_path ../data/sents.txt --do_lower_case --num_threads 1 --batch_size 1
分别实验了 batch_size=1, 32, 64, 128, 512, 1024,不同工具在 sents.txt (10098 条句子) 上的处理速度如下表所示:
batch_size | 1 | 32 | 64 | 128 | 512 | 1024 |
BertTokenizer | 13.142 | 12.124 | 12.321 | 12.522 | 12.454 | 12.679 |
BertTokenizerFast | 4.721 | 1.365 | 1.188 | 1.360 | 1.231 | 1.297 |
paddlenlp-FasterTokenizer (OMP_NUM_THREADS=1) | 3.402 | 2.628 | 2.637 | 2.653 | 2.850 | 2.947 |
paddlenlp-FasterTokenizer (OMP_NUM_THREADS=4) | — | 1.312 | 1.271 | 1.315 | 1.473 | 1.553 |
easytokenizer (num_threads=1) | 0.466 | 0.522 | 0.488 | 0.452 | 0.425 | 0.445 |
easytokenizer (num_threads=4) | — | 0.443 | 0.376 | 0.220 | 0.252 | 0.213 |
在 sents_17w.txt (179608 条句子) 上的测试结果如下:
batch_size | 1 | 32 | 64 | 128 | 512 | 1024 |
BertTokenizer | 128.097 | 115.988 | 113.817 | 115.690 | 116.672 | 115.622 |
BertTokenizerFast | 49.610 | 15.609 | 14.253 | 14.587 | 17.096 | 19.825 |
paddlenlp-FasterTokenizer (OMP_NUM_THREADS=1) | 41.160 | 37.597 | 36.285 | 38.918 | 40.626 | 39.269 |
paddlenlp-FasterTokenizer (OMP_NUM_THREADS=4) | — | 16.383 | 15.863 | 15.852 | 20.339 | 22.570 |
easytokenizer (num_threads=1) | 4.896 | 5.156 | 5.610 | 6.135 | 5.605 | 5.730 |
easytokenizer (num_threads=4) | — | 5.033 | 5.419 | 6.013 | 3.354 | 3.458 |
可以看出,easytokenizer 的处理速度显著超过其他工具。当 batch_size=1 时,单线程 (num_threads=1) 下的 easytokenizer 处理速度是 BertTokenizer 的 20 倍以上,是 BertTokenizerFast 和 paddlenlp-FasterTokenizer 的 7 倍以上。当 batch_size>=32 时,由于 tokenizers 库优秀的多线程性能,BertTokenizerFast 的处理速度显著提升,4 线程下的 paddlenlp-FasterTokenizer 与 BertTokenizerFast 性能接近,但它们仍低于单线程下的 easytokenizer。当使用 easytokenizer 的多线程并行处理时,建议文本批处理大小在 128 以上。
■Links
-
https://github.com/huggingface/transformers
-
https://github.com/huggingface/tokenizers
-
https://github.com/PaddlePaddle/PaddleNLP/tree/develop/fast_tokenizer
至此,本文的内容就结束了。