Una guía paso a paso para crear un tokenizador de subpalabras multilingüe en tailandés basado en un algoritmo BPE entrenado en conjuntos de datos en tailandés e inglés utilizando solo Python
La tarea principal del Tokenizador Implica traducir los textos de entrada sin procesar (en tailandés en nuestro caso, pero puede ser en cualquier idioma extranjero) a números y pasarlos a los transformadores del modelo. Luego, el transformador modelo genera resultados en forma de números. Una vez más, Tokenizador traduce estos números en texto comprensible para los usuarios finales. El siguiente diagrama de alto nivel describe el flujo explicado anteriormente.
En general, a la mayoría de nosotros sólo nos interesa cómo funciona la arquitectura del transformador del modelo. A menudo nos olvidamos de conocer en detalle algunos componentes importantes, como los tokenizadores. Comprender cómo funciona el tokenizador y tener un buen control sobre su funcionalidad nos brinda una buena ventaja para mejorar la precisión y el rendimiento de nuestro modelo.
Al igual que Tokenizer, algunos de los componentes más importantes de los procesos de implementación de LLM son el preprocesamiento de datos, la evaluación, las barreras de seguridad/seguridad y las pruebas/monitoreo. Le recomiendo que estudie estos temas con más detalle. Solo me di cuenta de la importancia de estos componentes después de trabajar en la implementación real de mi modelo multilingüe fundamental ThaiLLM en producción.
¿Por qué necesita un tokenizador tailandés o cualquier otro tokenizador de idioma extranjero?
- Suponga que utiliza tokenizadores genéricos basados en inglés para entrenar previamente un gran modelo de idioma multilingüe, como tailandés, hindi, indonesio, árabe, chino, etc. En este caso, es posible que su modelo no produzca un resultado adecuado para su dominio o casos de uso específicos. Por lo tanto, crear su propio tokenizador en el idioma que elija ciertamente ayuda a que el resultado de su modelo sea mucho más consistente y comprensible.
- Crear su propio tokenizador también le brinda control total sobre el vocabulario completo e inclusivo que desea crear. Durante el mecanismo de atención, gracias al vocabulario completo, el token puede atender y aprender de más tokens en la duración limitada del contexto de la secuencia. Por lo tanto, esto hace que el aprendizaje sea más consistente, lo que en última instancia contribuye a una mejor inferencia del modelo.
La buena noticia es que una vez que termines de crear Thai Tokenizer, podrás crear fácilmente un tokenizer en cualquier otro idioma. Todos los pasos de creación son iguales, excepto que necesitarás practicar con el conjunto de datos del idioma que elijas.
Ahora que tenemos todas las buenas razones para crear nuestro propio tokenizador, a continuación se detallan los pasos a seguir para crear nuestro tokenizador en tailandés.
- Construyamos nuestro propio algoritmo BPE
- Entrena al tokenizador
- Función de codificación y decodificación del tokenizador
- Cargar y probar el tokenizador
Paso 1: creemos nuestro propio algoritmo BPE (codificación de pares de bytes):
El algoritmo BPE se utiliza en muchos LLM populares, como Llama, GPT y otros, para crear su tokenizador. Podemos elegir cualquiera de estos tokenizadores LLM si nuestro modelo está basado en el idioma inglés. Dado que estamos construyendo el tokenizador tailandés, la mejor opción es crear nuestro propio algoritmo BPE desde cero y usarlo para crear nuestro tokenizador. Primero, comprendamos cómo funciona el algoritmo BPE utilizando el diagrama de flujo simple a continuación y luego comenzaremos a construirlo en consecuencia.
Los ejemplos de diagramas de flujo se presentan en inglés para facilitar la comprensión.
Escribamos un código para implementar el algoritmo BPE para nuestro Tokenizer tailandés.
# A simple practice example to get familiarization with utf-8 encoding to convert strings to bytes.
text = "How are you คุณเป็นอย่างไร" # Text string in both English and Thai
text_bytes = text.encode("utf-8")
print(f"Text in byte: {text_bytes}")text_list = list(text_bytes) # Converts text bytes to a list of integer
print(f"Text list in integer: {text_list}")
# As I don't want to reinvent the wheel, I will be referencing most of the code block from Andrej Karpathy's GitHub (https://github.com/karpathy/minbpe?tab=readme-ov-file).
# However, I'll be modifying code blocks specific to building our Thai language tokenizer and also explaining the codes so that you can understand how each code block works and make it easy when you implement code for your use case later.# This module provides access to the Unicode Character Database (UCD) which defines character properties for all Unicode characters.
import unicodedata
# This function returns a dictionary with consecutive pairs of integers and their counts in the given list of integers.
def get_stats(ids, stats=None):
stats = {} if stats is None else stats
# zip function allows to iterate consecutive items from given two list
for pair in zip(ids, ids[1:]):
# If a pair already exists in the stats dictionary, add 1 to its value else assign the value as 0.
stats[pair] = stats.get(pair, 0) + 1
return stats
# Once we find out the list of consecutive pairs of integers, we'll then replace those pairs with new integer tokens.
def merge(ids, pair, idx):
newids = []
i = 0
# As we'll be merging a pair of ids, hence the minimum id in the list should be 2 or more.
while i < len(ids):
# If the current id and next id(id+1) exist in the given pair, and the position of id is not the last, then replace the 2 consecutive id with the given index value.
if ids[i] == pair[0] and i < len(ids) - 1 and ids[i+1] == pair[1]:
newids.append(idx)
i += 2 # If the pair is matched, the next iteration starts after 2 positions in the list.
else:
newids.append(ids[i])
i += 1 # Since the current id pair didn't match, so start iteration from the 1 position next in the list.
# Returns the Merged Ids list
return newids
# This function checks that using 'unicodedata.category' which returns "C" as the first letter if it is a control character and we'll have to replace it readable character.
def replace_control_characters(s: str) -> str:
chars = []
for ch in s:
# If the character is not distorted (meaning the first letter doesn't start with "C"), then append the character to chars list.
if unicodedata.category(ch)[0] != "C":
chars.append(ch)
# If the character is distorted (meaning the first letter has the letter "C"), then replace it with readable bytes and append to chars list.
else:
chars.append(f"\\u{ord(ch):04x}")
return "".join(chars)
# Some of the tokens such as control characters like Escape Characters can't be decoded into valid strings.
# Hence those need to be replace with readable character such as �
def render_token(t: bytes) -> str:
s = t.decode('utf-8', errors='replace')
s = replace_control_characters(s)
return s
las dos funciones obtener_estadísticas Y unir Las implementaciones del algoritmo BPE para nuestro tokenizador tailandés se definen arriba en el bloque de código. Ahora el algoritmo está listo. Escribamos un código para entrenar nuestro tokenizador.
Paso 2: entrena el tokenizador:
Entrenar el tokenizador implica generar un vocabulario que es una base de datos de tokens únicos (palabras y subpalabras) junto con un número de índice único asignado a cada token. usaremos el conjunto de datos de Wiki tailandés de Hugging Face para formar nuestro tokenizador tailandés. Así como entrenar un LLM requiere una gran cantidad de datos, también necesitarás una buena cantidad de datos para entrenar un tokenizador. También puede utilizar el mismo conjunto de datos para entrenar el LLM y el tokenizador, aunque esto no es necesario. Para un LLM multilingüe, es recomendable utilizar los conjuntos de datos en inglés y tailandés en una proporción de 2:1, que es un enfoque estándar seguido por muchos profesionales.
Comencemos escribiendo el código de entrenamiento.
# Import Regular Expression
import regex as re # Create a Thai Tokenizer class.
class ThaiTokenizer():
def __init__(self):
# The byte pair should be done within the related words or sentences that give a proper context. Pairing between unrelated words or sentences may give undesirable output.
# To prevent this behavior, we'll implement the LLama 3 regular expression pattern to make meaningful chunks of our text before implementing the byte pair algorithm.
self.pattern = 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+"
self.compiled_pattern = re.compile(self.pattern)
# Special tokens are used to provide coherence in the sequence while training.
# Special tokens are assigned a unique index number and stored in vocabulary.
self.special_tokens = {
'<|begin_of_text|>': 1101,
'<|end_of_text|>': 1102,
'<|start_header_id|>': 1103,
'<|end_header_id|>': 1104,
'<|eot_id|>': 1105
}
# Initialize merges with empty dictionary
self.merges = {}
# Initialize the vocab dictionary by calling the function _build_vocab which is defined later in this class.
self.vocab = self._build_vocab()
# Tokenizer training function
def train(self, text, vocab_size):
# Make sure the vocab size must be at least 256 as the utf-8 encoding for the range 0-255 are same as the Ascii character.
assert vocab_size >= 256
# Total number of merges into the vocabulary.
num_merges = vocab_size - 256
# The first step is to make sure to split the text up into text chunks using the pattern defined above.
text_chunks = re.findall(self.compiled_pattern, text)
# Each text_chunks will be utf-8 encoded to bytes and then converted into an integer list.
ids = [list(ch.encode("utf-8")) for ch in text_chunks]
# Iteratively merge the most common pairs to create new tokens
merges = {} # (int, int) -> int
vocab = {idx: bytes([idx]) for idx in range(256)} # idx -> bytes
# Until the total num_merges is reached, find the common pair of consecutive id in the ids list and start merging them to create a new token
for i in range(num_merges):
# Count the number of times every consecutive pair appears
stats = {}
for chunk_ids in ids:
# Passing in stats will update it in place, adding up counts
get_stats(chunk_ids, stats)
# Find the pair with the highest count
pair = max(stats, key=stats.get)
# Mint a new token: assign it the next available id
idx = 256 + i
# Replace all occurrences of pair in ids with idx
ids = [merge(chunk_ids, pair, idx) for chunk_ids in ids]
# Save the merge
merges[pair] = idx
vocab[idx] = vocab[pair[0]] + vocab[pair[1]]
# Save class variables to be used later during tokenizer encode and decode
self.merges = merges
self.vocab = vocab
# Function to return a vocab dictionary combines with merges and special tokens
def _build_vocab(self):
# The utf-8 encoding for the range 0-255 are same as the Ascii character.
vocab = {idx: bytes([idx]) for idx in range(256)}
# Iterate through merge dictionary and add into vocab dictionary
for (p0, p1), idx in self.merges.items():
vocab[idx] = vocab[p0] + vocab[p1]
# Iterate through special token dictionary and add into vocab dictionary
for special, idx in self.special_tokens.items():
vocab[idx] = special.encode("utf-8")
return vocab
# After training is complete, use the save function to save the model file and vocab file.
# Model file will be used to load the tokenizer model for further use in llm
# Vocab file is just for the purpose of human verification
def save(self, file_prefix):
# Writing to model file
model_file = file_prefix + ".model" # model file name
# Model write begins
with open(model_file, 'w') as f:
f.write("thai tokenizer v1.0\n") # write the tokenizer version
f.write(f"{self.pattern}\n") # write the pattern used in tokenizer
f.write(f"{len(self.special_tokens)}\n") # write the length of special tokens
# Write each special token in the specific format like below
for tokens, idx in self.special_tokens.items():
f.write(f"{tokens} {idx}\n")
# Write only the keys part from the merges dict
for idx1, idx2 in self.merges:
f.write(f"{idx1} {idx2}\n")
# Writing to the vocab file
vocab_file = file_prefix + ".vocab" # vocab file name
# Change the position of keys and values of merge dict and store into inverted_merges
inverted_merges = {idx: pair for pair, idx in self.merges.items()}
# Vocab write begins
with open(vocab_file, "w", encoding="utf-8") as f:
for idx, token in self.vocab.items():
# render_token function processes tokens and prevents distorted bytes by replacing them with readable character
s = render_token(token)
# If the index of vocab is present in merge dict, then find its child index, convert their corresponding bytes in vocab dict and write the characters
if idx in inverted_merges:
idx0, idx1 = inverted_merges[idx]
s0 = render_token(self.vocab[idx0])
s1 = render_token(self.vocab[idx1])
f.write(f"[{s0}][{s1}] -> [{s}] {idx}\n")
# If index of vocab is not present in merge dict, just write it's index and the corresponding string
else:
f.write(f"[{s}] {idx}\n")
# Function to load tokenizer model.
# This function is invoked only after the training is complete and the tokenizer model file is saved.
def load(self, model_file):
merges = {} # Initialize merge and special_tokens with empty dict
special_tokens = {} # Initialize special_tokens with empty dict
idx = 256 # As the range (0, 255) is already reserved in vocab. So the next index only starts from 256 and onwards.
# Read model file
with open(model_file, 'r', encoding="utf-8") as f:
version = f.readline().strip() # Read the tokenizer version as defined during model file writing
self.pattern = f.readline().strip() # Read the pattern used in tokenizer
num_special = int(f.readline().strip()) # Read the length of special tokens
# Read all the special tokens and store in special_tokens dict defined earlier
for _ in range(num_special):
special, special_idx = f.readline().strip().split()
special_tokens[special] = int(special_idx)
# Read all the merge indexes from the file. Make it a key pair and store it in merge dictionary defined earlier.
# The value of this key pair would be idx(256) as defined above and keep on increase by 1.
for line in f:
idx1, idx2 = map(int, line.split())
merges[(idx1, idx2)] = idx
idx += 1
self.merges = merges
self.special_tokens = special_tokens
# Create a final vocabulary dictionary by combining merge, special_token and vocab (0-255). _build_vocab function helps to do just that.
self.vocab = self._build_vocab()
Paso 3: Función de codificación y decodificación del tokenizador:
- Codificación del tokenizador: La función de codificación del tokenizador examina el vocabulario y traduce los textos de entrada o las indicaciones dadas en una lista de identificadores enteros. Estos identificadores luego se introducen en los bloques de transformación.
- Decodificación del tokenizador: La función de decodificación del tokenizador examina el vocabulario y traduce la lista de identificadores generados a partir del bloque de clasificación del transformador en textos de salida.
Echemos un vistazo al diagrama a continuación para mayor claridad.
Escribamos un código para implementar la función de codificación y decodificación del tokenizador.
# Tokenizer encode function takes text as a string and returns integer ids list
def encode(self, text): # Define a pattern to identify special token present in the text
special_pattern = "(" + "|".join(re.escape(k) for k in self.special_tokens) + ")"
# Split special token (if present) from the rest of the text
special_chunks = re.split(special_pattern, text)
# Initialize empty ids list
ids = []
# Loop through each of parts in the special chunks list.
for part in special_chunks:
# If the part of the text is the special token, get the idx of the part from the special token dictionary and append it to the ids list.
if part in self.special_tokens:
ids.append(self.special_tokens[part])
# If the part of text is not a special token
else:
# Split the text into multiple chunks using the pattern we've defined earlier.
text_chunks = re.findall(self.compiled_pattern, text)
# All text chunks are encoded separately, then the results are joined
for chunk in text_chunks:
chunk_bytes = chunk.encode("utf-8") # Encode text to bytes
chunk_ids = list(chunk_bytes) # Convert bytes to list of integer
while len(chunk_ids) >= 2: # chunks ids list must be at least 2 id to form a byte-pair
# Count the number of times every consecutive pair appears
stats = get_stats(chunk_ids)
# Some idx pair might be created with another idx in the merge dictionary. Hence we'll find the pair with the lowest merge index to ensure we cover all byte pairs in the merge dict.
pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))
# Break the loop and return if the pair is not present in the merges dictionary
if pair not in self.merges:
break
# Find the idx of the pair present in the merges dictionary
idx = self.merges[pair]
# Replace the occurrences of pair in ids list with this idx and continue
chunk_ids = merge(chunk_ids, pair, idx)
ids.extend(chunk_ids)
return ids
# Tokenizer decode function takes a list of integer ids and return strings
def decode(self, ids):
# Initialize empty byte list
part_bytes = []
# Change the position of keys and values of special_tokens dict and store into inverse_special_tokens
inverse_special_tokens = {v: k for k, v in self.special_tokens.items()}
# Loop through idx in the ids list
for idx in ids:
# If the idx is found in vocab dict, get the bytes of idx and append them into part_bytes list
if idx in self.vocab:
part_bytes.append(self.vocab[idx])
# If the idx is found in inverse_special_tokens dict, get the token string of the corresponding idx, convert it to bytes using utf-8 encode and then append it into part_bytes list
elif idx in inverse_special_tokens:
part_bytes.append(inverse_special_tokens[idx].encode("utf-8"))
# If the idx is not found in both vocab and special token dict, throw an invalid error
else:
raise ValueError(f"invalid token id: {idx}")
# Join all the individual bytes from the part_byte list
text_bytes = b"".join(part_bytes)
# Convert the bytes to text string using utf-8 decode function. Make sure to use "errors=replace" to replace distorted characters with readable characters such as �.
text = text_bytes.decode("utf-8", errors="replace")
return text
Paso 4: cargue y pruebe el tokenizador:
Finalmente, aquí está la mejor parte de este artículo. En esta sección realizaremos dos tareas interesantes.
- Primero, entrene nuestro tokenizador con el conjunto de datos Thai Wiki de Hugging Face. Elegimos un tamaño de conjunto de datos pequeño (2,2 MB) para acelerar el entrenamiento. Sin embargo, para una implementación práctica, es necesario elegir un conjunto de datos mucho más grande para obtener mejores resultados. Una vez completado el entrenamiento, guardaremos el modelo.
- En segundo lugar, cargaremos la plantilla del tokenizador guardada y probaremos la función de codificación y decodificación del tokenizador.
Profundicemos.
# Train the tokenizerimport time # To caculate the duration of training completion
# Load training raw text data (thai_wiki dataset) from huggingface. thai_wiki_small.text: https://github.com/tamangmilan/thai_tokenizer
texts = open("/content/thai_wiki_small.txt", "r", encoding="utf-8").read()
texts = texts.strip()
# Define vocab size
vocab_size = 512
# Initialize a tokenizer model class
tokenizer = ThaiTokenizer()
# Start train a tokenizer
start_time = time.time()
tokenizer.train(texts, vocab_size)
end_time = time.time()
# Save tokenizer: you can change path and filename.
tokenizer.save("./models/thaitokenizer")
print(f"Total time to complete tokenizer training: {end_time-start_time:.2f} seconds")
# Output: Total time to complete tokenizer training: 186.11 seconds (3m 6s) [Note: Training duration will be longer if vocab_size is bigger and lesser for smaller vocab_size]
# Test the tokenizer# Initialize a tokenizer model class
tokenizer = ThaiTokenizer()
# Load tokenizer model. This model was saved during training.
tokenizer.load("./models/thaitokenizer.model")
# Invoke and verify the tokenizer encode and decode function for English Language
eng_texts = "When society evolved in different lands"
print(f"English Text: {eng_texts}")
encoded_ids = tokenizer.encode(eng_texts)
print(f"Encoded Ids: {encoded_ids}")
decoded_texts = tokenizer.decode(encoded_ids)
print(f"Decoded Texts: {decoded_texts}\n")
# Invoke and verify the tokenizer encode and decode function for Thai Language
thai_texts = "เมื่อสังคมมีวิวัฒนาการขึ้นในดินแดนต่าง"
print(f"Thai Text: {thai_texts}")
thai_encoded_ids = tokenizer.encode(thai_texts)
print(f"Encoded Ids: {thai_encoded_ids}")
thai_decoded_texts = tokenizer.decode(thai_encoded_ids)
print(f"Decoded Texts: {thai_decoded_texts}")
Perfecto. Nuestro tokenizador tailandés ahora puede codificar y decodificar con éxito y precisión textos en tailandés e inglés.
¿Has notado que los identificadores codificados de los textos en inglés son más largos que los identificadores codificados en tailandés? Esto se debe a que solo entrenamos nuestro tokenizador con el conjunto de datos tailandés. Por lo tanto, el tokenizador sólo es capaz de crear un vocabulario completo para el idioma tailandés. Como no entrenamos con un conjunto de datos en inglés, el tokenizador debe codificar directamente desde el nivel de carácter, lo que da como resultado identificadores codificados más largos. Como ya mencioné, para un LLM multilingüe debes entrenar los conjuntos de datos en inglés y tailandés con una proporción de 2:1. Esto le dará resultados equilibrados y de calidad.
¡Y eso es todo! Ahora hemos creado con éxito nuestro propio tokenizador tailandés desde cero utilizando únicamente Python. Y creo que fue genial. Con esto puedes crear fácilmente un tokenizador para cualquier idioma extranjero. Esto le dará mucha ventaja al implementar su LLM multilingüe.
¡Muchas gracias por leer!
Enlace al cuaderno de Google Colab
Referencias
[1] Andrej Karpathy, Git Hub: Carpthy/minbpe