Перейти к содержимому
ПереводыGoogle и DeepMind

AI Research Foundations, курс 1, часть 2: данные и обучение своей SLM

Перевод лабораторных 1.4–1.5 курса Google DeepMind «Build Your Own Small Language Model»: словарь и id токенов, дополнение и сдвиг последовательностей, батчи и обучение маленького трансформера на Keras.

Это перевод двух последних лабораторных курса 1 «Build Your Own Small Language Model». В первой части вы построили n-граммную модель и сравнили её с трансформером Gemma-1B. Теперь вы превратите тексты в числа, которые понимает нейросеть, и обучите собственную маленькую языковую модель (SLM, small language model) — трансформер примерно на 3,5 млн параметров.

ЛабораторнаяВремяКогда делатьЧто нужно
1.4. Готовим данные для обучения SLM30 минПосле урока «Зачем нужны токены»Python, pandas
1.5. Обучаем свою маленькую языковую модель40 мин + обучениеВместе с уроком «Цикл обучения LLM»Keras 3 на JAX, TensorFlow (только для tf.data), GPU желателен

Лабораторная 1.5 написана на Keras, а курс ChinaAI — на PyTorch. Модель здесь — «чёрный ящик» из пакета курса; как устроен трансформер внутри, вы разберёте в уроке «GPT на PyTorch» и в курсе 4. В конце страницы есть таблица соответствий Keras ↔ PyTorch и тот же конвейер данных на PyTorch.


Лабораторная 1.4. Подготовьте набор данных для обучения SLM

Заголовок раздела «Лабораторная 1.4. Подготовьте набор данных для обучения SLM»

Ноутбук: gdm_lab_1_4_prepare_the_dataset_for_training_a_slm.ipynb · открыть в Colab

Подготовьте набор данных так, чтобы его мог обработать трансформер. 30 минут.

Эта лабораторная проведёт вас через подготовку текстового набора данных для обучения маленькой языковой модели (SLM). SLM, которую вы будете обучать, — это трансформер. Трансформерам и вообще нейросетевым моделям нужны данные в определённом формате. В частности, при обработке текстов сначала нужно разбить текст на токены. Затем эти токены нужно перевести в уникальные числовые идентификаторы (id) — только тогда трансформер сможет их обработать.

В этой лабораторной вы сосредоточитесь на необходимых шагах предобработки (pre-processing): токенизации, построении словаря и сопоставлении токенам их id. Это фундамент для обучения вашей SLM в следующей лабораторной.

К концу лабораторной вы будете знать:

  • какие требования к формату данных у трансформеров и как переводить токены в id токенов и обратно;
  • как подготовить набор данных для обучения трансформера.

Как и в прошлых лабораторных, вы загрузите набор Africa Galore и токенизируете его токенизатором по пробелам. Затем вы соберёте всё необходимое, чтобы преобразовать набор данных в вид, пригодный для обучения трансформера.

В этой лабораторной вы:

  • загрузите набор данных и токенизируете его;
  • составите список всех токенов набора;
  • составите список уникальных токенов набора;
  • создадите отображение токенов в id и отображение id в токены;
  • напишете функции, которые переводят токены в соответствующие id и обратно;
  • опишете класс Python, который объединяет все методы, нужные для подготовки данных для трансформера.

В этой лабораторной вы используете пакет Pandas, чтобы прочитать набор данных. Запустите следующую ячейку, чтобы импортировать пакеты.

%%capture
!pip install "git+https://github.com/google-deepmind/ai-foundations.git@main"
import re # Для разбиения строк по пробелам.
# Используемые пакеты.
import pandas as pd # Для чтения набора данных.
import textwrap # Для переносов строк в абзацах.
# Для обратной связи.
from ai_foundations.feedback.course_1 import slm

Как и в прошлых лабораторных, вы снова используете набор Africa Galore.

Запустите следующую ячейку: она скачает набор данных и напечатает его первый абзац.

africa_galore = pd.read_json(
"https://storage.googleapis.com/dm-educational/assets/ai_foundations/africa_galore.json"
)
dataset = africa_galore["description"]
print(f"Loaded Africa Galore dataset with {len(dataset)} paragraphs.")
print(f"\nFirst paragraph:")
print(textwrap.fill(dataset[0]))

Как и для n-граммных моделей, перед обучением трансформера последовательности нужно токенизировать. Вы снова используете токенизатор по пробелам.

Запустите следующую ячейку, чтобы определить и проверить токенизатор space_tokenize. Он почти такой же, как тот, что вы уже видели. Только вместо строкового метода split он использует функцию re.split: она лучше справляется с текстами, где встречается несколько пробелов подряд.

def space_tokenize(text: str) -> list[str]:
"""Разбивает строку на список токенов.
Делит текст по пробелам.
Args:
text: Входной текст.
Returns:
Список токенов. Если текст пустой или состоит из одних пробелов,
возвращается пустой список.
"""
# Используем пакет `re`, чтобы правильно делить и по нескольким пробелам подряд.
tokens = re.split(r" +", text)
return tokens
# Токенизируем пример текста функцией `space_tokenize`.
space_tokenize("Kanga, a colorful printed cloth is more than just a fabric.")

Задание 1. Список всех токенов набора данных

Заголовок раздела «Задание 1. Список всех токенов набора данных»
tokens = []
# Ваш код здесь.
print(f"Total number of tokens in the Africa Galore dataset: {len(tokens):,}")
# Запустите эту ячейку, чтобы проверить код.
slm.test_build_tokens_list(tokens, space_tokenize, dataset)

Ожидаемый вывод (проверено нами): Total number of tokens in the Africa Galore dataset: 19,065.

Чтобы вспомнить, как выглядит токенизированный набор, запустите следующую ячейку: она напечатает первые 30 токенов первого абзаца.

tokens[:30]

Трансформеры работают с фиксированным набором токенов, которые они умеют обрабатывать и порождать. Этот набор называют словарём (vocabulary). Часто словарём делают список уникальных токенов, встречающихся в данных, на которых обучается модель.

def build_vocabulary(tokens: list[str]) -> list[str]:
# Строим список-словарь из множества токенов.
vocabulary = # Ваш код здесь.
return vocabulary
# Запустите эту ячейку, чтобы проверить код.
slm.test_build_vocabulary(build_vocabulary)

Теперь вашей функцией можно построить словарь для набора Africa Galore.

Запустите ячейку ниже: она создаст словарь и напечатает его размер, то есть число уникальных токенов в наборе.

vocabulary = build_vocabulary(tokens)
vocabulary_size = len(vocabulary)
print(
"Total number of unique tokens in the Africa Galore dataset:"
f" {vocabulary_size:,}"
)

Ожидаемый вывод: Total number of unique tokens in the Africa Galore dataset: 5,260.

Чтобы понять, как выглядит словарь, запустите следующую ячейку: она напечатает первые 30 записей словаря.

vocabulary[:30]

Обратите внимание: в отличие от первых 30 токенов набора данных, здесь нет повторов. Каждый токен входит в словарь ровно один раз.

Как говорилось выше, чтобы обучить трансформер на текстовом наборе, текстовые данные нужно превратить в список id токенов. Это числа, причём каждому токену однозначно соответствует своё число. Id должны идти подряд: если размер словаря равен kk, каждый токен должен получить id от 0 до k−1k-1.

На практике перевод между токенами и id реализуют двумя словарями Python:

  1. token_to_index сопоставляет каждому токену словаря его id (индекс). Индекс должен лежать между 0 и k−1k-1.
  2. index_to_token сопоставляет id (индексу) соответствующий токен (строку). По индексу от 0 до k−1k-1 он возвращает токен, стоящий на этой позиции.

Когда нужно превратить токен в число, используйте token_to_index. Когда нужно превратить число обратно в токен — index_to_token.

Следующая ячейка показывает, как построить отображение token_to_index по словарю. Если вы не знакомы с функцией Python enumerate, печатайте index и token на каждой итерации — так станет понятно, что она делает и почему этот код строит правильное отображение.

# Строим словарь `token_to_index`.
token_to_index = {}
for index, token in enumerate(vocabulary):
token_to_index[token] = index

Теперь создайте ниже словарь index_to_token, в котором ключ — индекс, а значение — токен. Он должен быть обратным к словарю token_to_index. Построив его, запустите ячейку и убедитесь, что токены и индексы в token_to_index и index_to_token совпадают.

# Создаём словарь, который сопоставляет индексу (числу)
# соответствующий токен из словаря.
index_to_token = {}
# Ваш код здесь.
# Запустите эту ячейку, чтобы проверить код.
slm.test_index_to_token(index_to_token, vocabulary)

Чтобы увидеть, что token_to_index и index_to_token — взаимно обратные отображения, посмотрите на десять записей обоих словарей:

print("token_to_index:\n")
count = 0
first_ten_indices = []
for token, token_id in token_to_index.items():
print(f"'{token}': {token_id}")
first_ten_indices.append(token_id)
count += 1
if count == 10:
break
print("\n\n")
print("index_to_token:\n")
for token_id in first_ten_indices:
print(f"{token_id}: '{index_to_token[token_id]}'")

Вы должны увидеть, что у первых десяти токенов id от нуля до девяти и что эти id переводятся обратно ровно в те же десять токенов.

Вместо того чтобы вручную переводить списки токенов в списки индексов, гораздо удобнее переходить между этими двумя представлениями данных с помощью функций encode и decode:

  • encode (кодировать) принимает строку текста и возвращает индексы её токенов;
  • decode (декодировать) принимает список индексов и возвращает соответствующий текст.

Следующая ячейка реализует обе функции. Запустите её, чтобы их определить.

def encode(text: str) -> list[int]:
"""Кодирует текст в список индексов по словарю.
Args:
text: Входной текст, который нужно закодировать.
Returns:
Список индексов, соответствующих токенам входного текста.
"""
# Переводим токены в индексы.
indices = []
for token in space_tokenize(text):
token_index = token_to_index.get(token)
indices.append(token_index)
return indices
def decode(indices: int | list[int]) -> list[str]:
"""Декодирует список целых чисел (или одно число) обратно в токены.
Args:
indices: Один индекс или список индексов, которые нужно перевести в токены.
Returns:
str: Строка из декодированных токенов, соответствующих входным индексам.
"""
# Если передано одно целое число, превращаем его в список.
if isinstance(indices, int):
indices = [indices]
# Переводим индексы в токены.
tokens = []
for index in indices:
token = index_to_token.get(index)
tokens.append(token)
# Склеиваем декодированные токены в одну строку.
return " ".join(tokens)

Чтобы убедиться, что функции работают как надо, закодируйте текст (токены превратятся в индексы), а затем декодируйте эти индексы. Декодирование должно вернуть исходный текст.

Следующая ячейка снова печатает первый абзац набора:

text = dataset[0]
print(text)

Запустите следующую ячейку, чтобы закодировать этот абзац и посмотреть на первые десять индексов.

encode(text)[:10]

Теперь декодируйте эти индексы — получатся первые десять токенов исходного абзаца. Они должны совпасть с началом абзаца выше.

decode(encode(text)[:10])

Вы реализовали самые важные шаги предобработки, которые готовят набор данных к обучению трансформера.

Чтобы не проходить эти шаги и не писать функции заново каждый раз, когда нужно обучить модель, удобно описать класс, например SimpleWordTokenizer, с методами для извлечения словаря, построения отображений token_to_index и index_to_token и с методами encode и decode.

Класс SimpleWordTokenizer ниже — надёжная основа для понимания методов токенизации, которые готовят вход для языковых моделей. Продолжая изучать языковые модели, вы встретите и другие техники токенизации с похожей структурой.

Обязательно разберите каждую часть класса, чтобы вспомнить, какие шаги нужны, чтобы построить словарь и переводить токены в id и обратно.

class SimpleWordTokenizer:
"""Простой токенизатор по словам. Его можно создать по корпусу текстов
или по готовому списку-словарю.
Токенизатор делит текст по пробелам; метод `encode` превращает текст
в последовательность индексов, а метод `decode` — индексы обратно в текст.
Типичный пример использования:
corpus = "Hello there!"
tokenizer = SimpleWordTokenizer(corpus)
print(tokenizer.encode('Hello'))
"""
def __init__(self, corpus: list[str], vocabulary: list[str] | None = None):
"""Создаёт токенизатор по текстам корпуса или по словарю.
Args:
corpus: Входной текстовый набор данных.
vocabulary: Заранее заданный словарь. Если None, словарь
автоматически строится по текстам.
"""
if vocabulary is None:
# Строим словарь с нуля.
if isinstance(corpus, str):
corpus = [corpus]
# Превращаем тексты в токены.
tokens = []
for text in corpus:
for token in self.space_tokenize(text):
tokens.append(token)
# Создаём словарь из уникальных токенов.
self.vocabulary = self.build_vocabulary(tokens)
else:
self.vocabulary = vocabulary
# Размер словаря.
self.vocabulary_size = len(self.vocabulary)
# Создаём отображения «токен → индекс» и «индекс → токен».
self.token_to_index = {}
self.index_to_token = {}
# Перебираем все токены словаря. enumerate автоматически
# присваивает каждому токену уникальный индекс.
for index, token in enumerate(self.vocabulary):
self.token_to_index[token] = index
self.index_to_token[index] = token
def space_tokenize(self, text: str) -> list[str]:
"""Делит текст на токены по пробелам.
Args:
text: Текст, который нужно разделить.
Returns:
Список токенов после разбиения `text`.
"""
# re.split, чтобы несколько пробелов подряд считались одним разделителем.
return re.split(" +", text)
def join_text(self, text_list: list[str]) -> str:
"""Склеивает список токенов в одну строку через пробел.
Args:
text_list: Список токенов, которые нужно склеить.
Returns:
Строка из всех токенов, разделённых пробелом.
"""
return " ".join(text_list)
def build_vocabulary(self, tokens: list[str]) -> list[str]:
"""Создаёт список-словарь по списку токенов.
Args:
tokens: Список токенов набора данных.
Returns:
Список уникальных токенов набора (словарь).
"""
return sorted(list(set(tokens)))
def encode(self, text: str) -> list[int]:
"""Кодирует текст в список индексов.
Args:
text: Входной текст, который нужно закодировать.
Returns:
Список индексов, соответствующих токенам входного текста.
"""
# Переводим токены в индексы.
indices = []
for token in self.space_tokenize(text):
token_index = self.token_to_index.get(token)
indices.append(token_index)
return indices
def decode(self, indices: int | list[int]) -> str:
"""Декодирует список целых чисел (или одно число) обратно в токены.
Args:
indices: Один индекс или список индексов.
Returns:
str: Строка из декодированных токенов.
"""
# Если передано одно целое число, превращаем его в список.
if isinstance(indices, int):
indices = [indices]
# Переводим индексы в токены.
tokens = []
for index in indices:
token = self.index_to_token.get(index)
tokens.append(token)
# Склеиваем декодированные токены в одну строку.
return self.join_text(tokens)

Чтобы убедиться, что этот класс делает то же самое, что ваши реализации выше, запустите следующую ячейку. Она проверяет, что первый абзац набора данных не меняется после кодирования и декодирования с помощью SimpleWordTokenizer.

tokenizer = SimpleWordTokenizer(dataset)
slm.test_simple_word_tokenizer(tokenizer, vocabulary, dataset)

Это конец лабораторной «Подготовьте набор данных для обучения SLM».

Лабораторная провела вас через шаги, необходимые, чтобы подготовить текстовый набор данных для обучения маленькой языковой модели (SLM). Вы занимались вот чем:

  • Загрузка и изучение данных. Вы посмотрели на структуру и содержание набора Africa Galore и примеры абзацев.
  • Токенизация текста. Простым методом токенизации по словам вы разбили текст на отдельные токены и составили словарь уникальных токенов.
  • Числовые представления. Вы сопоставили каждому токену уникальный числовой индекс, создав словари token_to_index и index_to_token, которые позволяют переходить от токенов к id и обратно.
  • Упаковка шагов в класс токенизатора. Вы разобрали объединённую версию логики токенизации и кодирования/декодирования в переиспользуемом классе SimpleWordTokenizer. Этот класс упрощает превращение текста в числовые данные для языковой модели и превращение выхода языковой модели в читаемый текст.

В следующей лабораторной вы токенизируете данные этим классом и обучите на них маленькую языковую модель.

Задание 1
# Вставьте этот код в ячейку задания выше, чтобы построить список токенов.
for paragraph in dataset:
for token in space_tokenize(paragraph):
tokens.append(token)
Задание 2
# Полная реализация функции build_vocabulary.
def build_vocabulary(tokens: list[str])-> list[str]:
# Строим список-словарь из множества токенов.
vocab = list(set(tokens))
return vocab
Задание 3
# Вставьте этот код в ячейку задания выше, чтобы построить
# отображение index_to_token.
for token, index in token_to_index.items():
index_to_token[index] = token

Лабораторная 1.5. Обучите свою маленькую языковую модель

Заголовок раздела «Лабораторная 1.5. Обучите свою маленькую языковую модель»

Ноутбук: gdm_lab_1_5_train_your_own_small_language_model.ipynb · открыть в Colab

Обучите трансформерную языковую модель на наборе Africa Galore. 40 минут.

В этой лабораторной вы используете шаги предобработки из прошлой лабораторной и подготовите данные для обучения трансформера. Затем вы обучите собственную маленькую языковую модель на наборе Africa Galore и исследуете её предсказания. Модель называют маленькой, потому что у неё сравнительно мало параметров (около 3,5 миллиона вместо примерно 1 миллиарда у Gemma-1B) и обучается она на маленьком наборе Africa Galore.

К концу лабораторной вы будете знать:

  • как подготовить текстовый набор данных для обучения трансформера на Keras;
  • как обучить и оценить маленькую языковую модель (SLM).

Вы будете использовать реализацию трансформера на Keras. Keras — открытый фреймворк глубокого обучения, в котором можно описывать архитектуры нейросетей и обучать модели с этими архитектурами. Как описывать модели самостоятельно, вы узнаете в следующих курсах. Пока что вы используете готовый код, чтобы задать модель и провести обучение.

В этой лабораторной вы:

  • загрузите набор данных, токенизируете его и переведёте в id токенов;
  • дополните (padding) набор так, чтобы все последовательности стали одной длины;
  • перемешаете примеры набора и сгруппируете их в батчи;
  • превратите данные во входы и целевые значения модели;
  • обучите трансформер.

Это довольно длинная лабораторная: чтобы обучить трансформерную языковую модель, нужно пройти много шагов, и само обучение занимает время. Если есть возможность, мы настоятельно рекомендуем запускать код на экземпляре Colab с GPU (инструкция — в разделе про Colab).

В этой лабораторной вы используете пакет Keras, чтобы описать и обучить трансформер, пакет Pandas, чтобы прочитать набор данных, и пакет TensorFlow, чтобы перемешать данные и сгруппировать их в батчи. Запустите следующую ячейку, чтобы импортировать пакеты.

%%capture
!pip install jax[cuda12]==0.7.2
!pip install "git+https://github.com/google-deepmind/ai-foundations.git@main"
# Используемые пакеты.
import os # Чтобы задать переменные конфигурации Keras.
os.environ["KERAS_BACKEND"] = "jax" # Задаём параметр Keras: вычисления — на JAX.
import re # Для разбиения текста по пробелам.
import keras # Чтобы описать и обучить модель.
import pandas as pd # Чтобы загрузить набор данных.
import tensorflow as tf # Чтобы перемешать набор данных.
# Для более понятных сообщений об ошибках.
from IPython.display import display, HTML
from ai_foundations import training # Чтобы обучить модель.
from ai_foundations import generation # Чтобы отправлять модели промпты.
from ai_foundations import visualizations # Чтобы рисовать вероятности.
from ai_foundations.feedback.course_1 import slm # Для обратной связи.
# Следующая строка настраивает Keras.
keras.utils.set_random_seed(812) # Фиксируем seed для слоёв Keras.

Загрузите набор данных. Как и в прошлых лабораторных, модель обучается на наборе Africa Galore.

africa_galore = pd.read_json(
"https://storage.googleapis.com/dm-educational/assets/ai_foundations/africa_galore.json"
)
dataset = africa_galore["description"].values
print("Loaded dataset with", dataset.shape[0], "paragraphs.")

В следующей ячейке — класс SimpleWordTokenizer из прошлой лабораторной. Вы снова используете его, чтобы токенизировать набор, подготовить словарь и переводить токены в id и обратно. Обратите внимание: эта версия добавляет в словарь специальные токены <PAD> и <UNK>. Зачем они нужны, вы узнаете в этой лабораторной.

Запустите следующую ячейку: она определит SimpleWordTokenizer, токенизирует Africa Galore и переведёт токены в id.

class SimpleWordTokenizer:
"""Простой токенизатор по словам.
Делит текст по пробелам; метод `encode` превращает текст
в последовательность индексов, а метод `decode` — индексы обратно в текст.
Его можно создать по корпусу или по готовому списку-словарю.
Типичный пример использования:
corpus = "Hello there!"
tokenizer = SimpleWordTokenizer(text)
print(tokenizer.encode('Hello'))
"""
# Задаём константы.
UNKNOWN_TOKEN = "<UNK>"
PAD_TOKEN = "<PAD>"
def __init__(self, corpus: list[str], vocabulary: list[str] | None = None):
"""Создаёт токенизатор по текстам корпуса или по словарю.
Args:
corpus: Входной текстовый набор данных.
vocabulary: Заранее заданный словарь. Если None, словарь
автоматически строится по текстам.
"""
if vocabulary is None:
# Строим словарь с нуля.
if isinstance(corpus, str):
corpus = [corpus]
# Превращаем тексты в токены.
tokens = []
for text in corpus:
for token in self.space_tokenize(text):
tokens.append(token)
# Создаём словарь из уникальных токенов.
vocabulary = self.build_vocabulary(tokens)
# Добавляем в словарь специальные токены: дополняющий и неизвестный.
self.vocabulary = (
[self.PAD_TOKEN] + vocabulary + [self.UNKNOWN_TOKEN]
)
else:
self.vocabulary = vocabulary
# Размер словаря.
self.vocabulary_size = len(self.vocabulary)
# Создаём отображения «токен → индекс» и «индекс → токен».
self.token_to_index = {}
self.index_to_token = {}
# Перебираем все токены словаря. enumerate автоматически
# присваивает каждому токену уникальный индекс.
for index, token in enumerate(self.vocabulary):
self.token_to_index[token] = index
self.index_to_token[index] = token
# Запоминаем id специальных токенов.
self.pad_token_id = self.token_to_index[self.PAD_TOKEN]
self.unknown_token_id = self.token_to_index[self.UNKNOWN_TOKEN]
def space_tokenize(self, text: str) -> list[str]:
"""Делит текст на токены по пробелам."""
# re.split, чтобы несколько пробелов подряд считались одним разделителем.
return re.split(" +", text)
def join_text(self, text_list: list[str]) -> str:
"""Склеивает список токенов в одну строку через пробел."""
return " ".join(text_list)
def build_vocabulary(self, tokens: list[str]) -> list[str]:
"""Создаёт список-словарь (уникальные токены) по списку токенов."""
return sorted(list(set(tokens)))
def encode(self, text: str) -> list[int]:
"""Кодирует текст в список индексов.
Args:
text: Входной текст, который нужно закодировать.
Returns:
Список индексов, соответствующих токенам входного текста.
"""
# Переводим токены в индексы. Токенов, которых нет в словаре,
# заменяем на id токена <UNK>.
indices = []
unk_index = self.token_to_index[self.UNKNOWN_TOKEN]
for token in self.space_tokenize(text):
token_index = self.token_to_index.get(token, unk_index)
indices.append(token_index)
return indices
def decode(self, indices: int | list[int]) -> str:
"""Декодирует список целых чисел (или одно число) обратно в токены.
Args:
indices: Один индекс или список индексов.
Returns:
Строка из декодированных токенов.
"""
# Если передано одно целое число, превращаем его в список.
if isinstance(indices, int):
indices = [indices]
# Переводим индексы в токены.
tokens = []
for index in indices:
token = self.index_to_token.get(index, self.unknown_token_id)
tokens.append(token)
# Склеиваем декодированные токены в одну строку.
return self.join_text(tokens)
# Создаём токенизатор. Он построит словарь из всех токенов,
# которые встречаются в наборе данных.
tokenizer = SimpleWordTokenizer(dataset)
# Переводим все токены в соответствующие id.
encoded_tokens = []
for text in dataset:
# Делим текст на токены и переводим токены в id.
token_ids = tokenizer.encode(text)
encoded_tokens.append(token_ids)

Чтобы убедиться, что всё прошло успешно, посмотрите на первые десять id токенов первого примера.

encoded_tokens[0][:10]

Чтобы понять, как выглядит набор и сколько дополнения понадобится, вычислите статистику длин абзацев.

Сначала посмотрите на длину первого абзаца:

print(f"Length of first paragraph: {len(encoded_tokens[0]):,}")
# Здесь вычислите длину самого короткого абзаца.
shortest_paragraph_length =
# Здесь вычислите длину самого длинного абзаца.
longest_paragraph_length =
print(f"Length of the shortest paragraph is:", shortest_paragraph_length)
print(f"Length of the longest paragraph is:", longest_paragraph_length)
# Запустите эту ячейку, чтобы проверить код.
slm.test_max_min_seqlen(
shortest_paragraph_length, longest_paragraph_length, encoded_tokens
)

Ожидаемый вывод: самый короткий абзац — 26 токенов, самый длинный — 318.

Теперь по этим данным можно задать целевую длину (max_length) для дополнения и обрезки абзацев. Ячейка ниже делает это «за кулисами» функцией keras.preprocessing.sequence.pad_sequences из пакета Keras.

Меняйте значение ниже и смотрите, как меняется список id токенов первого абзаца. Что происходит, если задать max_length очень маленьким? А если равным длине самого длинного абзаца?

# Задаём `max_length` для дополнения и обрезки данных.
max_length = 300 # @param {type: "number"}
if max_length <= 0:
display(
HTML(
f"<h3>Error:</h3><p>Max length must be greater than 0. Please"
f" increase <code>max_length</code>.</p><p></p>"
)
)
elif max_length > longest_paragraph_length:
display(
HTML(
f"<h3>Error:</h3><p>The padding token <code>"
f" {tokenizer.pad_token_id}</code> will be added to all"
f" sequences - you probably don't want that. Please reduce"
f" <code>max_length</code>.</p><p></p>"
)
)
else:
if max_length < longest_paragraph_length:
display(
HTML(
f"<p><strong>Note:</strong> The longest paragraph has"
f" {longest_paragraph_length} tokens,"
f" but <code>max_length</code> is set to {max_length}."
f" Paragraphs longer than <code>max_length</code> will be"
" truncated.</p><p></p>"
)
)
# padding="post" — дополняем в конце, truncating="post" — обрезаем конец.
padded_sequences = keras.preprocessing.sequence.pad_sequences(
encoded_tokens,
maxlen=max_length,
padding="post",
truncating="post",
value=tokenizer.pad_token_id,
)
print("New length of first paragraph:", len(padded_sequences[0]), "\n")
print(
"Padding makes the length of all sequences the same as the specified"
" `max_length`."
)
print(
"Notice the padded token IDs {tokenizer.pad_token_id} appearing at the"
f" end of the sequence.\n"
)
print("Padded tokens of first paragraph:\n", padded_sequences[0])

С max_length = 300 обрежутся только 2 абзаца из 232 (те, что длиннее 300 токенов), а id токена <PAD> равен 0: он первый в словаре.

Вспомните, что задача языковой модели — предсказывать следующий токен по контексту предыдущих токенов. N-граммную модель можно было «научить» этому, посчитав n-граммы в корпусе и вычислив вероятности прямо по счётчикам.

У трансформеров процедура обучения сложнее. Они раз за разом угадывают, каким должен быть следующий токен. Если догадка неверна, процедура обучения обновляет параметры модели — так в следующий раз верная догадка становится вероятнее.

Для этой процедуры данные нужно подготовить так, чтобы был отдельный набор входов и набор целей:

  • Вход (input) — последовательность токенов, которая подаётся в трансформер. Это может быть часть абзаца, целый абзац или даже несколько абзацев — в зависимости от того, как устроены данные. Во входе есть всё, кроме последнего токена: для последнего токена следующего нет.
  • Цель (target) — последовательность, которую модель должна предсказать по входу. Цель совпадает со входом, но сдвинута влево на один токен. Значит, цель всегда содержит следующий токен, который должен идти за входной последовательностью. В цели есть всё, кроме первого слова: трансформеру всегда нужен хотя бы один токен на входе, поэтому он начинает с предсказания следующего слова.

Например, если набор состоит из предложения «Table Mountain is beautiful», входная и целевая последовательности выглядят так:

  • вход: ["Table", "Mountain", "is"] (последний токен убран);
  • цель: ["Mountain", "is", "beautiful"] (сдвиг на один токен).

Как говорилось выше, на самом деле вход и цель будут состоять из id токенов, а не из самих токенов. Токены здесь показаны для наглядности.

Запустите следующую ячейку, чтобы подготовить входную и целевую последовательности для обучения трансформера.

# Готовим вход и цель для трансформера.
# Для каждого примера берём все токены, кроме последнего.
input_sequences = padded_sequences[:, :-1]
# Для каждого примера берём все токены, кроме первого.
target_sequences = padded_sequences[:, 1:]

Чтобы проверить, что вход и цель подготовлены правильно, напечатайте первые десять токенов входа и цели.

print("First 10 token IDs in first input sequence:", input_sequences[0, :10])
print(
"First 10 tokens in first input sequence:",
tokenizer.decode(input_sequences[0, :10]),
)
print("\n")
print("First 10 token IDs in first target sequence:", target_sequences[0, :10])
print(
"First 10 tokens in target sequence:",
tokenizer.decode(target_sequences[0, :10])
)

В выводе вы должны увидеть, что целевая последовательность — это входная, сдвинутая на один токен влево.

Когда вы задавали максимальную длину абзаца max_length, вы учитывали все токены, включая первый и последний токен каждого абзаца. Но в input_sequences вы убрали из каждого абзаца последний токен, а в target_sequences — первый. Поэтому максимальная длина данных в input_sequences и target_sequences теперь на один токен меньше.

Запустите следующую ячейку, чтобы обновить переменную max_length. Она станет параметром трансформера и должна точно отражать длину каждого (дополненного) абзаца во входных данных.

max_length = input_sequences.shape[1]

Ячейка ниже перемешивает набор и делит его на батчи. Результат — список матриц, которые называют тензорами (tensors). Каждая матрица соответствует батчу и содержит id всех токенов всех абзацев этого батча.

# Создаём набор данных TensorFlow, чтобы подготовить последовательности.
tf_dataset = tf.data.Dataset.from_tensor_slices((input_sequences, target_sequences))
# Случайно перемешиваем набор данных.
# buffer_size задаёт, сколько примеров набора держится в памяти
# при перемешивании.
# Если вы работаете с очень большим набором данных,
# уменьшите buffer_size по необходимости.
tf_dataset = tf_dataset.shuffle(buffer_size=len(input_sequences))
# Задаём размер батча.
batch_size = 32 # @param {type: "number"}
# Создаём батчи.
batches = tf_dataset.batch(batch_size)
for batch in batches.take(1):
print(batch)

Запустите следующую ячейку, чтобы посчитать общее число батчей:

total_batches = 0
for batch in batches:
total_batches += 1
print("Total number of batches is:", total_batches)

Ожидаемый вывод: Total number of batches is: 8 — семь полных батчей по 32 абзаца и последний из 8 абзацев (232 = 7 · 32 + 8).

Вся подготовительная работа сделана, и можно обучать маленькую языковую модель. Как говорилось выше, у этой модели около 3,5 миллиона параметров. Она гораздо меньше так называемых больших языковых моделей, которые используются в продуктах. Например, у модели Google Gemini миллиарды параметров, и обучали её на наборе данных гораздо больше Africa Galore.

Размер трансформера и объём обучающих данных сильно влияют на качество. Большие модели с множеством параметров способны выучить более сложные закономерности и точнее предсказывать. Но им нужно и больше вычислительных ресурсов, памяти и вычислительной мощности. Это может означать более долгое обучение — то есть больше времени, чтобы модель, обновляясь, достигла наилучшего качества, — и более высокую стоимость. Очень большую модель в ноутбуке Colab не обучить, поэтому здесь вы обучите гораздо меньшую. Несмотря на это, процесс обучения большой языковой модели в целом такой же, как процесс обучения маленькой.

Функция create_model ниже строит трансформер. Она принимает такие параметры:

  • max_length — максимальная длина абзаца в наборе (вы задали её выше). Модель сможет обрабатывать последовательности не длиннее этого значения.
  • vocabulary_size — размер словаря, то есть число уникальных токенов в наборе. Он используется двояко. Во-первых, по нему определяется, сколько разных входов модель должна ожидать. Во-вторых, он задаёт, сколько разных токенов модель может предсказывать. Это значение можно взять у токенизатора, который вы создали выше, через его свойство vocabulary_size.
  • learning_rate — скорость обучения: насколько быстро обновляются параметры. Если поставить её выше, обучение может ускориться, но модель может получиться хуже. Если ниже — модель, скорее всего, будет учиться лучше, но обучение может замедлиться. Пока это значение менять не нужно; подробнее о нём вы узнаете в следующих курсах.
model = training.create_model(
max_length=max_length,
vocabulary_size=tokenizer.vocabulary_size,
learning_rate=1e-4
)

Обучение может занять время. Хочется убедиться, что предсказания модели действительно улучшаются. Один из способов — задать функцию обратного вызова (callback), которая регулярно печатает, что модель сгенерировала бы для одного промпта.

Например, функция обратного вызова из следующей ячейки печатает десять токенов для промпта «Abeni,» после каждых 10 итераций обучения.

prompt = "Abeni,"
prompt_ids = tokenizer.encode(prompt)
text_gen_callback = training.TextGenerator(
max_tokens=10, start_tokens=prompt_ids, tokenizer=tokenizer, print_every=10
)

Запустите следующую ячейку, чтобы обучить модель. Как говорилось выше, процесс обучения обновляет параметры модели после обработки каждого батча. Это называют шагом (step) обучения.

Эпоха — это обработка всех батчей набора. Перед обучением нужно задать, сколько раз процесс обучения пройдёт по набору данных, — число эпох (num_epochs).

Скорее всего, лучшие результаты получатся, если обучать модель хотя бы 200 эпох. Но если обучение идёт долго, число эпох можно уменьшить. Если после 200 эпох модель работает плохо, её можно дообучить ещё несколько эпох: поменяйте число ниже и запустите ячейку снова. Обучение продолжится с того места, где остановилось.

Если хотите начать обучение заново, перезапустите предыдущие ячейки перед тем, как запускать ячейку ниже.

num_epochs = 200 # @param {type: "number"}
# verbose=2: метод model.fit печатает по одной строке на эпоху,
# чтобы вы видели, как падает loss и улучшаются генерируемые тексты.
history = model.fit(
x=batches, verbose=2, epochs=num_epochs, callbacks=[text_gen_callback]
)

Пока модель обучается, следите, как меняется генерируемый текст. В начале обучения генерация, скорее всего, будет случайным набором слов. Но к концу обучения она должна стать связнее.

Кроме генерируемого текста можно следить за тем, как по ходу обучения меняется функция потерь (loss). Если модель обучается правильно, loss должен снижаться. Возможно, от эпохи к эпохе loss будет иногда немного подрастать. Об этом не стоит беспокоиться: главное, чтобы общая тенденция была к снижению.

Когда обучение закончится (это может занять время), модели можно давать промпты, как в прошлых лабораторных.

Обучив модель, исследователи проводят множество оценок, чтобы понять, хорошо ли она работает в разных ситуациях.

Последнее задание — оценить и вашу модель. Остаток лабораторной проведёт вас через процесс оценки. Чтобы оценить качество модели, вы зададите ключевые вопросы:

  • A. Насколько хорошо модель предсказывает следующий токен для промпта, опираясь на закономерности из обучающего набора?
  • B. Связен ли сгенерированный текст и осмыслен ли он в данном контексте?
  • C. Ожидаемый ли получается следующий токен, если слегка изменить контекст?

Во время оценки полезно делать заметки — добавьте ячейки в ноутбук или пишите в любом удобном инструменте.

Насколько хорошо модель предсказывает следующий токен, опираясь на закономерности обучающего набора?

Заголовок раздела «Насколько хорошо модель предсказывает следующий токен, опираясь на закономерности обучающего набора?»

Вот как можно ответить на этот вопрос:

  • Дайте модели промпт из токена или последовательности токенов из обучающего набора. Например, начните с "Abeni, a bright-eyed" («Абени, ясноглазая…»).
  • Нарисуйте распределение вероятностей следующего токена для этого промпта.
  • Увеличьте num_tokens_to_generate, чтобы получить тексты длиннее.
  • Изучите сгенерированный текст. Посмотрите, насколько хорошо модель научилась порождать текст, отражающий закономерности, выученные при обучении.
prompt = "Abeni, a bright-eyed" #@param {type: "string"}
num_tokens_to_generate = 10 #@param {type: "number"}
generated_text, probs = generation.generate_text(
prompt,
num_tokens_to_generate,
model=model,
tokenizer=tokenizer,
pad_token_id=tokenizer.pad_token_id,
sampling_mode="greedy" # Генерируем самое вероятное продолжение.
)
print("Generated text:", generated_text)
print("\n")
visualizations.plot_next_token(probs[0], prompt=prompt, tokenizer=tokenizer)

Связен ли сгенерированный текст и осмыслен ли он в контексте?

Заголовок раздела «Связен ли сгенерированный текст и осмыслен ли он в контексте?»
  • Дайте модели промпт из выбранного вами токена или фразы.
  • Увеличьте num_tokens_to_generate, чтобы получить тексты длиннее.
  • Нарисуйте распределение вероятностей следующего токена для промпта.
  • Оцените качество сгенерированных текстов.

Заметьте: выше генерация всегда выбирала самый вероятный следующий токен из кандидатов. В следующей ячейке генерация сэмплирует следующий токен по распределению, которое предсказала модель. Для этого параметр sampling_mode равен random.

prompt = "Jide was hungry so she went looking for" #@param {type: "string"}
num_tokens_to_generate = 10 #@param {type: "number"}
generated_text, probs = generation.generate_text(
prompt,
num_tokens_to_generate,
model=model,
tokenizer=tokenizer,
pad_token_id=tokenizer.pad_token_id,
sampling_mode="random", # Сэмплируем по распределению модели.
)
print("Generated text:", generated_text)
print("\n")
visualizations.plot_next_token(probs[0], prompt=prompt, tokenizer=tokenizer)

Ожидаемый ли следующий токен, если слегка изменить контекст?

Заголовок раздела «Ожидаемый ли следующий токен, если слегка изменить контекст?»
  • Слегка измените контекст промпта.
  • Нарисуйте распределение вероятностей следующего токена для промпта.
  • Увеличьте num_tokens_to_generate, чтобы получить тексты длиннее.
  • Оцените качество сгенерированных текстов.
prompt = "Jide was thirsty so she went looking for" #@param {type: "string"}
num_tokens_to_generate = 10 #@param {type: "number"}
generated_text, probs = generation.generate_text(
prompt,
num_tokens_to_generate,
model=model,
tokenizer=tokenizer,
pad_token_id=tokenizer.pad_token_id,
sampling_mode="random",
)
print("Generated text:", generated_text)
print("\n")
visualizations.plot_next_token(probs[0], prompt=prompt, tokenizer=tokenizer)

Это конец лабораторной «Обучите свою маленькую языковую модель».

В этой лабораторной вы обучили свою первую SLM и прошли такие шаги:

  • Токенизировали набор данных. С помощью SimpleWordTokenizer из прошлой лабораторной вы токенизировали абзацы набора и перевели их в id токенов.
  • Дополнили абзацы. Вы сделали все абзацы одной длины: одни обрезали, другие дополнили специальным токеном "<PAD>". Это необходимо для обработки данных нейросетями, например трансформерными языковыми моделями.
  • Подготовили входы и цели. Вы создали пары «вход — цель», где цель — это вход, сдвинутый на один токен. Так модель учится предсказывать следующий токен по контексту предыдущих.
  • Перемешали данные и разбили на батчи. Вы перемешали набор, чтобы данные внутри каждого батча были разнообразнее, и сгруппировали абзацы в батчи для обучения.
  • Обучили SLM. Вы задали и обучили маленький трансформер и наблюдали, как во время обучения снижается loss.
  • Дали обученной модели промпты. Вы поэкспериментировали с промптами и посмотрели, насколько модель способна предсказывать вероятное следующее слово, порождать связный текст и подстраиваться под изменения контекста.

Во время оценки вы, возможно, заметили, что некоторые предсказания модели не так хороши, как у модели Gemma. Это ожидаемо: ваша модель гораздо меньше Gemma и обучена на намного меньшем объёме текстов. Тем не менее она должна порождать грамматически правильные предложения, пусть и не всегда очень осмысленные.

В следующей части курса вы чуть глубже разберёте оценку моделей, а затем подумаете, какие задачи вам интересно решать с помощью языковых моделей.

Задание 1
# Вставьте этот код в ячейку задания 1 выше.
longest_paragraph_length = len(max(encoded_tokens, key=len))
shortest_paragraph_length = len(min(encoded_tokens, key=len))

Для тех, кто идёт по курсу ChinaAI, — соответствия между тем, что вы видели в лабораторной, и тем, как это делается в PyTorch (уроки 3.3.2 и 4.6.3).

Лабораторная 1.5 (Keras, TF)PyTorch
keras.preprocessing.sequence.pad_sequences(..., padding="post", truncating="post")обрезать список и заполнить тензор torch.full(..., pad_id) или torch.nn.utils.rnn.pad_sequence
padded[:, :-1], padded[:, 1:]точно так же: срезы тензора
tf.data.Dataset.from_tensor_slices(...).shuffle(...).batch(32)DataLoader(TensorDataset(x, y), batch_size=32, shuffle=True)
training.create_model(...)свой nn.Module: nn.Embedding для токенов и позиций, блок внимания, nn.Linear на словарь
CustomMaskPadLoss (ignore_class=pad)F.cross_entropy(..., ignore_index=pad_id)
model.fit(epochs=200, callbacks=[...])свой цикл: for epoch in range(200): for x, y in loader: ...
sampling_mode="greedy" / "random"logits.argmax(-1) / torch.multinomial(probs, 1)

Ниже — подготовка данных из лабораторной на PyTorch (мы проверили её на Africa Galore; файл africa_galore.json нужно скачать по ссылке выше):

import json, re
import torch
import torch.nn.functional as F
from torch.utils.data import TensorDataset, DataLoader
dataset = [x["description"] for x in json.load(open("africa_galore.json"))]
# Тот же словарь, что у SimpleWordTokenizer: <PAD> первым (id 0), <UNK> последним.
words = sorted(set(w for p in dataset for w in re.split(r" +", p)))
vocabulary = ["<PAD>"] + words + ["<UNK>"]
token_to_index = {t: i for i, t in enumerate(vocabulary)}
pad_id, unk_id = 0, len(vocabulary) - 1
def encode(text):
return [token_to_index.get(t, unk_id) for t in re.split(r" +", text)]
max_length = 300
# Обрезаем длинные абзацы и дополняем короткие справа — как pad_sequences(padding="post", truncating="post").
padded = torch.full((len(dataset), max_length), pad_id, dtype=torch.long)
for i, text in enumerate(dataset):
ids = encode(text)[:max_length]
padded[i, : len(ids)] = torch.tensor(ids)
inputs, targets = padded[:, :-1], padded[:, 1:] # сдвиг на один токен
loader = DataLoader(TensorDataset(inputs, targets), batch_size=32, shuffle=True)
print(len(vocabulary), inputs.shape, len(loader))
# Функция потерь, которая не учитывает <PAD>, — аналог CustomMaskPadLoss.
logits = torch.randn(32, max_length - 1, len(vocabulary)) # заглушка вместо выхода модели
x, y = next(iter(loader))
loss = F.cross_entropy(logits.reshape(-1, len(vocabulary)), y.reshape(-1), ignore_index=pad_id)
print(round(loss.item(), 2))

Ожидаемый вывод: 5262 torch.Size([232, 299]) 8, затем число около 9 (случайные логиты дают loss чуть выше ln⁡5262≈8,57\ln 5262 \approx 8{,}57). Подставьте вместо заглушки свою GPT из урока 4.6.2 — и у вас будет полный аналог лабораторной 1.5 на PyTorch.

EnglishРусский
small language model (SLM)маленькая языковая модель
pre-processingпредобработка
vocabulary, vocabulary sizeсловарь, размер словаря
token ID, indexid токена, индекс
encode / decodeкодировать / декодировать
padding, truncating, <PAD>дополнение, обрезка, дополняющий токен
unknown / unseen token, <UNK>неизвестный / невиданный токен
input, targetвход, цель (целевая последовательность)
batch, batch size, shufflingбатч, размер батча, перемешивание
tensorтензор
epoch, iteration, stepэпоха, итерация, шаг
learning rateскорость обучения
lossфункция потерь, loss
callbackфункция обратного вызова
inferenceинференс, применение модели