| |
|
|
| import logging |
| import os |
| from copy import deepcopy |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, cast |
|
|
| import tiktoken |
| import torch |
| from tiktoken.load import load_tiktoken_bpe |
|
|
| from core.data.conversation import REGISTERED_CONVS |
| from core.tokenizer import TikTokenTokenizer, Tokenizer |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class Llama3Tokenizer(TikTokenTokenizer): |
| """ |
| Tokenizing and encoding/decoding text using the Tiktoken tokenizer. |
| """ |
|
|
| special_tokens: Dict[str, int] |
|
|
| num_reserved_special_tokens = 256 |
|
|
| pat_str = r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+" |
|
|
| def __init__(self, model_path: str): |
| """ |
| Initializes the Tokenizer with a Tiktoken model. |
| |
| Args: |
| model_path (str): The path to the Tiktoken model file. |
| """ |
| assert os.path.isfile(model_path), model_path |
|
|
| mergeable_ranks = load_tiktoken_bpe(model_path) |
| num_base_tokens = len(mergeable_ranks) |
| special_tokens = [ |
| "<|begin_of_text|>", |
| "<|end_of_text|>", |
| "<|image|>", |
| "<|reserved_special_token_1|>", |
| "<|reserved_special_token_2|>", |
| "<|reserved_special_token_3|>", |
| "<|start_header_id|>", |
| "<|end_header_id|>", |
| "<|reserved_special_token_4|>", |
| "<|eot_id|>", |
| ] + [ |
| f"<|reserved_special_token_{i}|>" |
| for i in range(5, self.num_reserved_special_tokens - 5) |
| ] |
| self.special_tokens = { |
| token: num_base_tokens + i for i, token in enumerate(special_tokens) |
| } |
| self.model = tiktoken.Encoding( |
| name=Path(model_path).name, |
| pat_str=self.pat_str, |
| mergeable_ranks=mergeable_ranks, |
| special_tokens=self.special_tokens, |
| ) |
| logger.info(f"Reloaded tiktoken model from {model_path}") |
|
|
| self.n_words: int = self.model.n_vocab |
| |
| self.bos_id: int = self.special_tokens["<|begin_of_text|>"] |
| self.eos_id: int = self.special_tokens["<|end_of_text|>"] |
| self.eot_id: int = self.special_tokens["<|eot_id|>"] |
| self.pad_id: int = -1 |
| self.stop_tokens = { |
| self.special_tokens["<|end_of_text|>"], |
| self.special_tokens["<|eot_id|>"], |
| } |
| logger.info( |
| f"#words: {self.n_words} - BOS ID: {self.bos_id} - EOS ID: {self.eos_id}" |
| ) |
|
|
| def encode( |
| self, |
| s: str, |
| add_bos: bool, |
| add_eos: bool, |
| ) -> List[int]: |
| """ |
| Encodes a string into a list of token IDs. |
| |
| Args: |
| s (str): The input string to be encoded. |
| add_bos (bool): Whether to prepend the beginning-of-sequence token. |
| add_eos (bool): Whether to append the end-of-sequence token. |
| |
| Returns: |
| list[int]: A list of token IDs. |
| """ |
| assert type(s) is str |
|
|
| |
| |
| TIKTOKEN_MAX_ENCODE_CHARS = 4000_000 |
|
|
| |
| |
| |
| MAX_NO_WHITESPACES_CHARS = 250_000 |
|
|
| substrs = ( |
| substr |
| for i in range(0, len(s), TIKTOKEN_MAX_ENCODE_CHARS) |
| for substr in self._split_whitespaces_or_nonwhitespaces( |
| s[i : i + TIKTOKEN_MAX_ENCODE_CHARS], MAX_NO_WHITESPACES_CHARS |
| ) |
| ) |
| t: List[int] = [] |
| for substr in substrs: |
| t.extend( |
| self.model.encode( |
| substr, |
| allowed_special="all", |
| disallowed_special=(), |
| ) |
| ) |
| if add_bos: |
| t.insert(0, self.bos_id) |
| if add_eos: |
| t.append(self.eos_id) |
| return t |
|
|
| def decode(self, t: Sequence[int]) -> str: |
| """ |
| Decodes a list of token IDs into a string. |
| |
| Args: |
| t (List[int]): The list of token IDs to be decoded. |
| |
| Returns: |
| str: The decoded string. |
| """ |
| |
| return self.model.decode(cast(List[int], t)) |
|
|
| @staticmethod |
| def _split_whitespaces_or_nonwhitespaces( |
| s: str, max_consecutive_slice_len: int |
| ) -> Iterator[str]: |
| """ |
| Splits the string `s` so that each substring contains no more than `max_consecutive_slice_len` |
| consecutive whitespaces or consecutive non-whitespaces. |
| """ |
| current_slice_len = 0 |
| current_slice_is_space = s[0].isspace() if len(s) > 0 else False |
| slice_start = 0 |
|
|
| for i in range(len(s)): |
| is_now_space = s[i].isspace() |
|
|
| if current_slice_is_space ^ is_now_space: |
| current_slice_len = 1 |
| current_slice_is_space = is_now_space |
| else: |
| current_slice_len += 1 |
| if current_slice_len > max_consecutive_slice_len: |
| yield s[slice_start:i] |
| slice_start = i |
| current_slice_len = 1 |
| yield s[slice_start:] |
|
|
|
|
| @dataclass |
| class PLMTokenizedSample: |
| is_valid: bool = True |
| text_ids: List[int] = field(default_factory=list) |
| image_pos: List[int] = field(default_factory=list) |
| response_pos: List[int] = field(default_factory=list) |
| num_media_chunks: int = 0 |
|
|
|
|
| |
| class PLMTokenizer(Llama3Tokenizer): |
| def __init__( |
| self, |
| model_path: str, |
| patch_size: Optional[int] = None, |
| pooling_ratio: Optional[float] = None, |
| seq_len: Optional[int] = 2048, |
| conversation_format: Optional[str] = "plm_sft", |
| image_token: Optional[str] = "<|image|>", |
| bos_token: Optional[str] = "<|begin_of_text|>", |
| eos_token: Optional[str] = "<|end_of_text|>", |
| ): |
| super().__init__(model_path=model_path) |
|
|
| self.patch_size = patch_size |
| self.pooling_ratio = pooling_ratio |
| self.seq_len = seq_len |
| self.conversation_template = REGISTERED_CONVS[conversation_format] |
| self.image_token = image_token |
|
|
| self.bos_token_id = self.special_tokens[bos_token] |
| self.eos_token_id = self.special_tokens[eos_token] |
| self.pad_token_id = self.pad_id |
| self.image_token_id = self.special_tokens[self.image_token] |
| self.eos_id = self.eos_token_id |
| self.n_words = self.n_words |
|
|
| def __call__( |
| self, |
| conversations: List[Any], |
| media: Optional[torch.Tensor] = None, |
| media_type: Optional[str] = "image", |
| ) -> PLMTokenizedSample: |
| conv_template = self.conversation_template.copy() |
| assert self.image_token == conv_template.image_token |
| conv_template.add_conv(deepcopy(conversations)) |
|
|
| num_media_chunks = media.size(0) |
| if media_type in ["image", "multi_image", "video"]: |
| assert self.patch_size is not None |
| assert self.pooling_ratio is not None |
| width, height = media.size(-2), media.size(-1) |
| num_patches = int( |
| (width // self.patch_size // self.pooling_ratio) |
| * (height // self.patch_size // self.pooling_ratio) |
| ) |
| dialog = conv_template.get_conversation_dict_list( |
| num_images=num_media_chunks, |
| num_patches=num_patches, |
| media_type=media_type, |
| ) |
| elif media_type == "text": |
| |
| dialog = conv_template.get_conversation_dict_list( |
| num_images=0, |
| num_patches=0, |
| media_type=media_type, |
| ) |
| else: |
| NotImplementedError( |
| f"The supported media types are ['image', 'multi_image', 'video', 'text'], \ |
| but found {media_type} which is not supported" |
| ) |
|
|
| text_ids = [] |
| source_ids = [] |
| response_ids = [] |
| response_pos = [] |
| for msg in dialog: |
| for role, text in msg.items(): |
| tokens = self.encode(text, add_bos=False, add_eos=False) |
| if role == "assistant": |
| response_ids.extend(tokens) |
| else: |
| source_ids.extend(tokens) |
| if ( |
| len(text_ids) + len(source_ids) + len(response_ids) + 1 |
| > self.seq_len |
| ): |
| if len(text_ids) == 0: |
| return PLMTokenizedSample(is_valid=False) |
| logger.info(f"Truncated text length to {len(text_ids) + 1}") |
| break |
| text_ids.extend(source_ids) |
| response_pos.extend( |
| [i + len(text_ids) for i in range(len(response_ids))] |
| ) |
| text_ids.extend(response_ids) |
| source_ids = [] |
| response_ids = [] |
|
|
| image_pos = [i for i, t in enumerate(text_ids) if t == self.image_token_id] |
| return PLMTokenizedSample( |
| text_ids=text_ids, |
| image_pos=image_pos, |
| response_pos=response_pos, |
| num_media_chunks=num_media_chunks, |
| ) |
|
|
| def _tokenize_for_generation( |
| self, |
| question: List[Any], |
| media: Optional[torch.Tensor] = None, |
| ): |
| if media is not None: |
| width, height = media.size(-2), media.size(-1) |
| num_patches = int( |
| (width // self.patch_size // self.pooling_ratio) |
| * (height // self.patch_size // self.pooling_ratio) |
| ) |
| prompt = self.conversation_template.get_generation_prompt( |
| question, num_images=len(media), num_patches=num_patches |
| ) |
| text_ids = self.encode(prompt, add_bos=False, add_eos=False) |
| image_pos = [i for i, t in enumerate(text_ids) if t == self.image_token_id] |
| else: |
| raise NotImplementedError(f"Text-only inference is not supported yet.") |
|
|
| return text_ids, image_pos |
|
|
| def decode_batch(self, tokens: torch.Tensor) -> List[str]: |
| return [self.decode(tokens[i].tolist()) for i in range(tokens.size(0))] |
|
|
|
|
| def build_tokenizer(name: str, path: str, **kwargs) -> Tokenizer: |
| if name == "llama3": |
| return Llama3Tokenizer(path) |
| elif name == "plmchat": |
| return PLMTokenizer(path, **kwargs) |
| else: |
| raise NotImplementedError(f"{name} tokenizer type is not implemented") |
|
|