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

AI Research Foundations, курс 1, часть 1: распределения и n-граммы

Перевод лабораторных 1.1–1.3 курса Google DeepMind «Build Your Own Small Language Model»: распределение над следующим словом, n-граммная модель своими руками и сравнение с трансформером Gemma.

Это перевод первых трёх лабораторных курса 1 программы AI Research Foundations. Курс называется «Build Your Own Small Language Model» («Постройте свою маленькую языковую модель»). Лабораторные идут друг за другом: сначала вы сами придумываете вероятности для следующего слова, потом заставляете компьютер посчитать их по корпусу текстов (n-граммная модель), а в конце сравниваете свою модель с настоящим трансформером Gemma-1B. Продолжение курса — часть 2: готовим данные и обучаем свою SLM.

ЛабораторнаяВремяКогда делатьЧто нужно
1.1. Своё распределение вероятностей15 минВместе с уроком «Что такое языковая модель»Только Python
1.2. Эксперименты с n-граммными моделями30 минВместе с уроком «Биграммная модель»Python, pandas
1.3. Сравниваем n-граммы и трансформер30 минПосле урока «Генерация текста и сэмплирование»GPU желателен, JAX и библиотека gemma

В нашем уроке 4.1.2 биграммы строятся по буквам русских имён, здесь — по словам англоязычного корпуса. Идея одна и та же, и полезно увидеть её с двух сторон. Лабораторные 1.1 и 1.2 спокойно идут на CPU; как запустить 1.3 у себя на машине с видеокартами, рассказано в обзоре программы.


Лабораторная 1.1. Создайте своё распределение вероятностей

Заголовок раздела «Лабораторная 1.1. Создайте своё распределение вероятностей»

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

Исследуйте основы языковых моделей: предскажите следующее слово для заданного промпта. 15 минут.

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

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

Промпт, который будет встречаться на протяжении всего курса, — «Jide was hungry so she went looking for» («Джиде проголодалась и пошла искать…»). Он взят с изменениями из статьи Эльдана и Ли 2023 года TinyStories: How Small Can Language Models Be and Still Speak Coherent English? [1].

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

  • как вероятности влияют на выбор следующего слова;
  • как контекст должен определять распределение вероятностей над следующим словом.

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

  • присвоите вероятности словам-кандидатам — насколько вероятно, что каждое из них подходит как следующее слово для промпта;
  • с помощью функции random.choices и заданных вероятностей выберете слово-кандидат;
  • посмотрите, как изменение промпта (например, hungry → thirsty, «проголодалась» → «захотела пить», или замена подлежащего) меняет распределение и предсказанное слово.

Все шаги подробно описаны ниже.

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

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

%%capture
# Устанавливаем специальный пакет курса.
!pip install "git+https://github.com/google-deepmind/ai-foundations.git@main"
# Используемые пакеты.
# Для случайного выбора элементов из распределения вероятностей.
import random
# Специальные функции, которые дают обратную связь по вашим решениям.
from ai_foundations.feedback.course_1 import probabilities

В следующих заданиях вы будете присваивать вероятности словам-кандидатам. Слова-кандидаты для промпта — это слова, которые могли бы служить его продолжением.

Ячейка ниже задаёт вероятности для слов-кандидатов «star», «beef», «bottle» («звезда», «говядина», «бутылка»), которые продолжают пример промпта «Twinkle twinkle little». Как видите, слову «star» присвоена высокая вероятность: так продолжается знаменитая детская колыбельная «Twinkle, Twinkle Little Star» («Мерцай, мерцай, звёздочка»).

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

candidate_words = ["star", "beef", "bottle"]
# Вероятности для каждого слова задаются в этом списке.
your_mental_model = [0.99, 0.001, 0.009]
# Эта функция выполняет несколько проверок: действительно ли your_mental_model
# задаёт корректное распределение вероятностей.
probabilities.test_probabilities(candidate_words, your_mental_model)

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

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

chosen_word = random.choices(candidate_words, weights=your_mental_model)
print(f"Twinkle twinkle little {chosen_word[0]}.")

Задание 1. Вероятности для другого промпта

Заголовок раздела «Задание 1. Вероятности для другого промпта»

Теперь повторите то же самое для другого промпта — «Jide was hungry so she went looking for». В ячейке ниже уже задан список слов-кандидатов для продолжения: ["food", "snacks", "leftovers", "her", "for", "water", "photosynthesis", "pyramid"] («еда», «перекус», «остатки еды», «её», «для», «вода», «фотосинтез», «пирамида»).

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

candidate_words = [
"food",
"snacks",
"leftovers",
"her",
"for",
"water",
"photosynthesis",
"pyramid",
]
# Впишите вероятности слов-кандидатов, которые продолжают
# промпт "Jide was hungry so she went looking for".
your_mental_model = []
# Эта функция выполняет несколько проверок: действительно ли your_mental_model
# задаёт корректное распределение вероятностей.
probabilities.test_probabilities(candidate_words, your_mental_model)

Теперь выберите слово-кандидат функцией random.choices. Запустите ячейку несколько раз и посмотрите, какие слова выпадают чаще. Обратите внимание, как меняется предложение.

chosen_word = random.choices(candidate_words, weights=your_mental_model)
print(f"Jide was hungry so she went looking for {chosen_word[0]} ...")

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

В следующем задании вы увидите, как изменение контекста должно менять распределение вероятностей следующего слова. Возьмите промпт, очень похожий на прежний: «Jide was thirsty so she went looking for» («Джиде захотела пить и пошла искать…»).

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

Список слов-кандидатов тот же: «food», «snacks», «leftovers», «her», «for», «water», «photosynthesis» и «pyramid».

candidate_words = [
"food",
"snacks",
"leftovers",
"her",
"for",
"water",
"photosynthesis",
"pyramid",
]
# Впишите вероятности слов-кандидатов, которые продолжают
# промпт "Jide was thirsty so she went looking for".
your_mental_model = []
# Эта функция выполняет несколько проверок: действительно ли your_mental_model
# задаёт корректное распределение вероятностей.
probabilities.test_probabilities(candidate_words, your_mental_model)

Запустите ячейку ниже и посмотрите, как random.choices дополняет промпт с новыми вероятностями.

chosen_word = random.choices(candidate_words, weights=your_mental_model)
print(f"Jide was thirsty so she went looking for {chosen_word[0]}...")

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

💭 Подумайте. Остановитесь и поразмышляйте. Для каких слов вы изменили вероятности в новом контексте? Как изменение промпта повлияло на ваш выбор вероятностей? Какая часть нового контекста сделала одни слова заметнее, а другие — бледнее? Размышление о том, как вы поменяли распределение, подсказывает, как идеальная языковая модель перестраивает вероятности слов, чтобы порождать ответы, учитывающие контекст (context-aware).

Это конец лабораторной «Создайте своё распределение вероятностей».

В ноутбуке разобраны фундаментальные понятия языковых моделей:

  1. Присваивание вероятностей. Вы научились присваивать вероятности словам-кандидатам для данного промпта и с помощью функции random.choices моделировать выбор слова по этим вероятностям. Это показало стохастическую (случайную) природу языковых моделей: выбор слова управляется вероятностями, а не повторяет каждый раз один и тот же процесс.
  2. Влияние контекста. Вы увидели, что изменение контекста промпта заметно меняет распределение вероятностей слов-кандидатов. Для этого вы сравнили вероятности, которые задали для «Jide was hungry…» и «Jide was thirsty…». Так видно, как контекст влияет на выбор слова. Это важная идея для построения современных языковых моделей.
Задание 1
# Вставьте этот код в ячейку задания 1 выше.
your_mental_model=[0.2, 0.346, 0.2, 0.09, 0.001, 0.16, 0.002, 0.001]
Задание 2
# Вставьте этот код в ячейку задания 2 выше.
your_mental_model=[0.05, 0.046, 0.09, 0.05, 0.001, 0.76, 0.002, 0.001]

Проверьте себя: сумма в обоих решениях равна 1 (0,2 + 0,346 + 0,2 + 0,09 + 0,001 + 0,16 + 0,002 + 0,001 = 1,000). Во втором решении «water» получает 0,76 — в три с лишним раза больше, чем любое слово в первом.


Лабораторная 1.2. Эксперименты с n-граммными моделями

Заголовок раздела «Лабораторная 1.2. Эксперименты с n-граммными моделями»

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

Потренируйтесь извлекать счётчики n-грамм и узнайте, как построить на них языковую модель. 30 минут.

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

К концу лабораторной вы сможете:

  • разбивать абзацы набора данных на похожие на слова единицы — токены; этот процесс называется токенизацией;
  • оценивать вероятности n-граммной языковой модели по набору данных;
  • с помощью n-граммной модели предсказывать отдельные токены и более длинные продолжения.

Как говорилось в предыдущей статье, n-грамма — это непрерывная последовательность из nn слов. N-граммная модель использует такие последовательности, чтобы оценить вероятность следующего слова по предшествующей последовательности из n−1n-1 слов (контексту).

Напомним, что вероятность P(B∣A)P(\mathrm{B} \mid \mathrm{A}), где B\mathrm{B} — следующее слово, а A\mathrm{A} — контекст, вычисляется так:

P(B∣A)=Count(A B)Count(A)P(\mathrm{B} \mid \mathrm{A}) = \frac{\mathrm{Count}(\mathrm{A\ B})}{\mathrm{Count}(\mathrm{A})}

Словами: вероятность того, что после контекста A идёт слово B, равна доле случаев, когда за A действительно следовало B. Например, если «Table Mountain» встречается в корпусе 10 раз и 7 из них за ним идёт «is», то P(is∣Table Mountain)=7/10=0,7P(\text{is} \mid \text{Table Mountain}) = 7/10 = 0{,}7.

Счётчики полных n-грамм Count(A B)\mathrm{Count}(\mathrm{A\ B}) и счётчики n-грамм контекста Count(A)\mathrm{Count}(\mathrm{A}) можно получить, посчитав n-граммы в наборе данных (dataset). Для языковой модели таким набором обычно служит коллекция текстов, которую также называют корпусом (text corpus).

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

  • зададите набор данных и разобьёте предложения на отдельные токены;
  • построите из токенов n-граммы и посчитаете их счётчики Count(A B)\mathrm{Count}(\mathrm{A\ B});
  • оцените P(B∣A)P(\mathrm{B} \mid \mathrm{A}) по счётчикам n-грамм;
  • по оценённым распределениям P(B∣A)P(\mathrm{B} \mid \mathrm{A}) сгенерируете новый текст своей n-граммной языковой моделью.

Код лабораторной использует пакет random для сэмплирования из распределений, типы данных Counter и defaultdict для подсчёта n-грамм и пакет pandas (pd) для построения таблиц.

%%capture
!pip install "git+https://github.com/google-deepmind/ai-foundations.git@main"
# Используемые пакеты.
import random # Для сэмплирования из распределений вероятностей.
from collections import Counter, defaultdict # Для подсчёта n-грамм.
import textwrap # Для автоматических переносов строк в длинных текстах.
import pandas as pd # Для построения и показа таблиц.
# Специальные функции, которые дают обратную связь по вашим решениям.
from ai_foundations.feedback.course_1 import ngrams

Начните с загрузки набора данных, по которому будете считать n-граммы. Для этого вы обработаете набор Africa Galore.

Набор Africa Galore создан специально для этого курса. Он состоит из синтетически сгенерированных абзацев о самых разных сторонах культуры, истории и географии Африки; их сгенерировала языковая модель Google Gemini. Поскольку данные синтетические, они чистые — в них нет шума и несогласованностей, которые часто встречаются в реальных наборах данных. Но по той же причине тексты не всегда звучат так естественно, как написанные людьми.

Набор предназначен для обучения, и при генерации его направляли так, чтобы содержание вращалось вокруг тем, нужных в упражнениях лабораторных. Процесс генерации вдохновлён проектом TinyStories [1].

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

africa_galore = pd.read_json(
"https://storage.googleapis.com/dm-educational/assets/ai_foundations/africa_galore.json"
)
dataset = africa_galore["description"]
print(f"The dataset consists of {dataset.shape[0]} paragraphs.")

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

for paragraph in dataset[:10]:
# textwrap автоматически расставляет переносы строк, чтобы длинный текст было удобнее читать.
formatted_paragraph = textwrap.fill(paragraph)
print(f"{formatted_paragraph}\n")

Помните, что n-грамма — это последовательность из nn слов. Но сейчас каждый абзац — одна длинная строка. Чтобы разбить набор данных на n-граммы и посчитать их для построения языковой модели, нужно разбить последовательности на отдельные слова. Этот процесс называется токенизацией.

Простейший токенизатор — токенизатор по пробелам (space tokenizer). Он разбивает предложения на слова по пробелам, то есть по символам, которые появляются при нажатии клавиши пробела. Например, токенизатор по пробелам разобьёт предложение «Bimpe didn’t buy the rice» на список слов ["Bimpe", "didn't", "buy", "the", "rice"].

Ячейка ниже реализует токенизатор по пробелам в функции space_tokenize.

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

Токенизатор по пробелам довольно наивен. Если делить текст только по пробелам, знаки препинания часто оказываются частью слов. Например, «Table mountain is tall.» и «Table mountain is tall» дадут разные списки слов. Первое предложение даёт ["Table", "mountain", "is", "tall."], а второе, без точки в конце, — ["Table", "mountain", "is", "tall"]. Поскольку единицы, на которые токенизатор делит последовательности, не всегда являются словами, их обычно называют токенами. Часто токен совпадает со словом, но это может быть и строка-не-слово, например «tall.» или «3/4».

Более совершенные способы токенизации вы изучите в следующих курсах. Для этой лабораторной токенизатора по пробелам достаточно: используйте реализацию space_tokenize из предыдущей ячейки.

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

space_tokenize(dataset[0])

Функция space_tokenize возвращает список отдельных токенов. Но чтобы вычислить условную вероятность того, что токен B\mathrm{B} идёт после контекста A\mathrm{A}, P(B∣A)P(\mathrm{B} \mid \mathrm{A}), нужно знать, сколько раз в наборе данных встречаются все n-граммы и (n−1)-граммы.

Например, чтобы вычислить вероятность токена «is» после биграммы (2-граммы) «Table Mountain», нужны счётчики триграммы (3-граммы) «Table Mountain is» и биграммы «Table Mountain». В общем случае для n-граммной языковой модели нужны счётчики всех n-грамм и (n−1)-грамм. Первый шаг к ним — функция, которая превращает список токенов в список n-грамм.

all_unigrams = []
all_bigrams = []
all_trigrams = []
def generate_ngrams(text: str, n: int) -> list[tuple[str]]:
"""Строит n-граммы для данного текста.
Args:
text: Входная строка.
n: Размер n-грамм (например, 2 для биграмм, 3 для триграмм).
Returns:
Список n-грамм, каждая из которых представлена кортежем токенов.
"""
# Токенизируем текст.
tokens = ... # Ваш код здесь.
# Строим список n-грамм.
ngrams = []
# Ваш код здесь.
return ngrams
for paragraph in dataset:
# Вызов `generate_ngrams` с n=1 строит список униграмм.
all_unigrams.extend(generate_ngrams(paragraph, n=1))
# Вызов `generate_ngrams` с n=2 строит список биграмм (2-грамм).
all_bigrams.extend(generate_ngrams(paragraph, n=2))
# Вызов `generate_ngrams` с n=3 строит список триграмм (3-грамм).
all_trigrams.extend(generate_ngrams(paragraph, n=3))
print("First 10 Unigrams:", all_unigrams[:10])
print("First 10 Bigrams:", all_bigrams[:10])
print("First 10 Trigrams:", all_trigrams[:10])
# Запустите эту ячейку, чтобы проверить свою реализацию.
ngrams.test_generate_ngrams(generate_ngrams, space_tokenize)

Главная причина считать n-граммы и превращать счётчики в вероятности — эти вероятности улавливают закономерности (patterns) языка. Поэтому частые n-граммы обычно интереснее очень редких: они отражают частые совместные появления слов (например, «Table Mountain» или «jollof rice»).

Запустите следующую ячейку, чтобы найти самые частые n-граммы набора данных. Её вывод — список кортежей вида (n-грамма, число появлений). Например, из вывода видно, что биграмма ("is", "a") встречается в Africa Galore 144 раза.

# Считаем все биграммы с помощью типа Counter из Python.
# См.: https://docs.python.org/3/library/collections.html#collections.Counter
bigram_counts = Counter(all_bigrams)
# Печатаем десять самых частых биграмм.
print("Most common bigrams:")
for bigram, count in bigram_counts.most_common(10):
print(f" ({bigram}, {count})")
# Считаем все триграммы тем же Counter.
trigram_counts = Counter(all_trigrams)
# Печатаем десять самых частых триграмм.
print("\n\nMost common trigrams:")
for trigram, count in trigram_counts.most_common(10):
print(f" ({trigram}, {count})")

Чтобы вычислять вероятности, нужна функция, которая возвращает счётчики n-грамм.

def get_ngram_counts(dataset: list[str], n: int) -> dict[str, Counter]:
"""Считает n-граммы в наборе данных.
Функция принимает список строк (абзацев или предложений), строит n-граммы
для каждого текста и создаёт словарь, в котором:
* ключи — контексты `context` длиной n-1 токенов;
* значения — объекты Counter `counts`, такие что `counts[next_token]` —
сколько раз `next_token` шёл после `context`.
Args:
dataset: Список строк набора данных.
n: Размер строящихся n-грамм (например, 2 для биграмм, 3 для триграмм).
Returns:
Словарь, где ключи — контексты из (n-1) токенов, а значения — объекты
Counter со счётчиками каждого следующего токена для этого контекста.
"""
# Задаём словарь как defaultdict, который автоматически создаёт пустой
# Counter для нового ключа. Благодаря этому можно читать и менять
# ngram_counts[context][next_token], не создавая заранее ни
# ngram_counts[context], ни ngram_counts[context][next_token].
# Подробнее о типах defaultdict и Counter:
# https://docs.python.org/3/library/collections.html#collections.Counter
# https://docs.python.org/3/library/collections.html#collections.defaultdict
ngram_counts = defaultdict(Counter)
for paragraph in dataset:
# Ваш код здесь.
...
return dict(ngram_counts)
# Пример использования функции.
example_data = [
"This is an example sentence.",
"Another example sentence.",
"Split a sentence."
]
ngram_counts = get_ngram_counts(example_data, 2)
# Печатаем словарь счётчиков биграмм для набора из трёх примеров.
print("Bigram counts dictionary:\n")
print("{")
for context, counter in ngram_counts.items():
print(f" '{context}': {counter},")
print("}")

Как видно в выводе, биграмма «example sentence.» встретилась 2 раза — это видно в записи для "example".

# Запустите эту ячейку, чтобы проверить свою реализацию.
ngrams.test_ngram_counts(get_ngram_counts, generate_ngrams)

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

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

bigram_counts = get_ngram_counts(dataset, n=2)
# Показываем счётчики в виде таблицы средствами pandas.
bigram_counts_matrix = {
context: dict(counts) for context, counts in bigram_counts.items()
}
bigram_data_frame = pd.DataFrame.from_dict(
bigram_counts_matrix, orient="index").fillna(0)
display(bigram_data_frame)
zero_count = (bigram_data_frame == 0).sum().sum()
print(
f"Number of bigrams with a count of 0: {zero_count:,}"
f" ({zero_count/bigram_data_frame.size * 100:.2f}%)"
)

Как видно из таблицы, очень многие клетки равны 0. Дело в том, что таблица содержит (почти) все возможные сочетания всех токенов Africa Galore. Всего возможных сочетаний 5 143×5 1765\,143 \times 5\,176, но подавляющее большинство из них (99,95%) в наборе не встречается ни разу. Эта разреженность (sparsity) — важное свойство, которое нужно учитывать при построении n-граммных моделей. Для любого контекста A\mathrm{A} вероятность P(B∣A)P(\mathrm{B} \mid \mathrm{A}) будет равна 0 для большинства токенов B\mathrm{B}.

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

trigram_counts = get_ngram_counts(dataset, n=3)
# Показываем счётчики в виде таблицы средствами pandas.
trigram_counts_matrix = {
context: dict(counts) for context, counts in trigram_counts.items()
}
trigram_data_frame = pd.DataFrame.from_dict(
trigram_counts_matrix, orient="index").fillna(0)
display(trigram_data_frame)
zero_count = (trigram_data_frame == 0).sum().sum()
print(
f"Number of trigrams with a count of 0: {zero_count:,}"
f" ({zero_count/trigram_data_frame.size * 100:.2f}%)"
)

Эта таблица ещё больше: в ней по клетке на каждое сочетание биграммы (строки) и токена (столбцы), встречающихся в наборе. Для Africa Galore это 13 411×5 14213\,411 \times 5\,142 сочетаний. Здесь доля нулевых клеток ещё выше — 99,98%. Держите разреженность в голове, когда будете превращать счётчики в вероятности и генерировать по ним тексты.

В предыдущих заданиях вы подготовили всё для последнего вычисления: вероятности того, что токен B\mathrm{B} идёт после контекста A\mathrm{A}:

P(B∣A)=Count(A B)Count(A)P(\mathrm{B} \mid \mathrm{A}) = \frac{\mathrm{Count}(\mathrm{A\ B})}{\mathrm{Count}(\mathrm{A})}

Функцией get_ngram_counts можно вычислить и Count(A B)\mathrm{Count}(\mathrm{A\ B}), и Count(A)\mathrm{Count}(\mathrm{A}). Например, чтобы оценить вероятности триграммной модели с контекстом длины 2, можно посчитать числитель и знаменатель для набора dataset так:

# Счётчики для числителя.
trigram_counts = get_ngram_counts(dataset, n=3)
# Счётчики для знаменателя.
bigram_counts = get_ngram_counts(dataset, n=2)

Но есть небольшой трюк: счётчики биграмм можно получить прямо из счётчиков триграмм, не вызывая get_ngram_counts второй раз.

Чтобы увидеть, как он работает, возьмите счётчики всех триграмм, которые начинаются с биграммы «a staple» («основной продукт, неотъемлемая часть»). Они лежат в словаре trigram_counts, построенном выше:

context = "a staple"
trigram_counts[context]

Счётчик в выводе показывает, что в наборе есть такие триграммы, начинающиеся с «a staple»:

  • «a staple food» (1 раз);
  • «a staple in» (6 раз);
  • «a staple dish» (2 раза);
  • «a staple throughout» (1 раз);
  • «a staple of» (1 раз);
  • «a staple at» (1 раз);
  • «a staple beverage» (1 раз).

Трюк в том, что счётчик биграммы «a staple» равен сумме счётчиков триграмм, которые с неё начинаются. Из счётчика выше получаем 1+6+2+1+1+1+1=131+6+2+1+1+1+1 = 13.

Следующая ячейка показывает, как сделать это автоматически функцией sum() и методом values() у счётчика.

context = "a staple"
# Вычисляем счётчик биграммы "a staple" через sum().
bigram_count_a_staple = sum(trigram_counts[context].values())
print(
'Bigram count of "a staple" computed indirectly from trigram counts: ',
bigram_count_a_staple,
)
# Берём счётчик "a staple" напрямую из bigram_counts.
print('Bigram count of "a staple" computed directly: ',
bigram_counts["a"]["staple"])

Как показывает вывод, оба способа — через sum() и напрямую — дают одно и то же число.

def build_ngram_model(
dataset: list[str],
n: int
) -> dict[str, dict[str, float]]:
"""Строит n-граммную языковую модель.
Функция принимает список строк (абзацев или предложений), строит по ним
n-граммы с помощью get_ngram_counts и превращает счётчики в вероятности.
Получившаяся модель — словарь, где ключи — контексты из (n-1) токенов,
а значения — словари, сопоставляющие возможным следующим токенам их
условные вероятности при данном контексте.
Args:
dataset: Список строк — набор данных.
n: Размер n-грамм (например, 2 для биграммной модели).
Returns:
Словарь — n-граммная языковая модель: ключи — контексты из (n-1)
токенов, значения — словари «следующий токен → условная вероятность».
"""
# Словарь для хранения P(B | A).
# В ngram_model[context][token] должна лежать P(token | context).
ngram_model = {}
# Берём счётчики, которые вычисляет функция get_ngram_counts.
ngram_counts = get_ngram_counts(dataset, n)
# Перебираем возможные контексты. `context` — строка,
# а `next_tokens` — словарь «возможный следующий токен → сколько раз
# он шёл после `context`».
for context, next_tokens in ngram_counts.items():
# Здесь вычислите Count(A) и P(B | A).
# Ваш код здесь.
...
return ngram_model
# Проверяем функцию на простой триграммной модели.
test_dataset = ["Table Mountain is tall.", "Table Mountain is beautiful."]
test_trigram_model = build_ngram_model(test_dataset, n=3)
test_trigram_model
# Запустите эту ячейку, чтобы проверить свою реализацию.
ngrams.test_build_ngram_model(build_ngram_model, get_ngram_counts)

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

trigram_model = build_ngram_model(dataset, n=3)

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

print(f"P(B | \"as it\") = {trigram_model['as it']}")
print(f"P(B | \"as they\") = {trigram_model['as they']}")

💭 Подумайте. Осмысленны ли вероятности, оценённые по набору данных? Отражают ли они какие-то знакомые вам закономерности или правила английского языка?

Напоследок посмотрите на распределения для других контекстов. Начните с контекста «The name».

context = "The name"
trigram_model[context]

Теперь запустите тот же код со слегка изменённым контекстом «Their name».

context = "Their name"
trigram_model[context]

Как вы, вероятно, заметили, этот код падает с ошибкой. Причина в том, что биграммы «Their name» в наборе данных нет. Значит, её нет и в триграммной модели, построенной по этому набору, и в словаре с вероятностями нет записи для этой биграммы — отсюда KeyError.

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

Задание 4. Сэмплируем следующий токен по вероятностям n-грамм

Заголовок раздела «Задание 4. Сэмплируем следующий токен по вероятностям n-грамм»

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

Для начала вспомните код из предыдущей лабораторной, который с помощью random.choices выбирал токен из списка кандидатов; он повторён в следующей ячейке.

Раньше вы задавали возможные следующие токены и их вероятности вручную. В этом задании вы возьмёте следующие токены и их вероятности из n-граммной модели.

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

# Задаём список токенов.
example_candidate_tokens = ["apple", "banana", "cherry"]
# Задаём вероятности для каждого фрукта.
probabilities = [0.2, 0.5, 0.3]
# Выбираем один фрукт с учётом вероятностей.
# Параметр k=1 просит функцию вернуть один элемент.
chosen_fruit = random.choices(
example_candidate_tokens,
weights=probabilities,
k=1)[0]
print("Chosen fruit:", chosen_fruit)
context = "looking for"
candidate_tokens = []
candidate_tokens_probabilities = []
# Берём из `trigram_model` токены-кандидаты и их вероятности.
# Ваш код здесь.
print(f"Candidate tokens: {candidate_tokens}")
print(f"Candidate token probabilities: {candidate_tokens_probabilities}")
# Сэмплируем из списка кандидатов в соответствии с их вероятностями.
next_token = random.choices(candidate_tokens,
weights=candidate_tokens_probabilities)[0]
print("\n\nSampled next token:")
print(context, next_token)
# Запустите, чтобы проверить код или получить подсказку.
ngrams.test_candidate_tokens(
trigram_model, candidate_tokens, candidate_tokens_probabilities
)

Теперь вы разберёте функцию, которая порождает новые тексты по промпту, используя вероятности n-граммной модели.

Генерация текста n-граммной моделью — итеративный процесс: каждый новый токен добавляется к контексту, и это становится основой для предсказания следующего.

Начиная с промпта, модель по распределению, полученному из счётчиков n-грамм, выбирает следующий токен — снова с помощью random.choices. Сгенерированный токен добавляется к контексту, и по обновлённой последовательности вычисляется следующее распределение. Эта цепочка продолжается, пока не будет сгенерировано num_tokens_to_generate токенов.

Этот итеративный процесс реализует функция generate_next_n_tokens:

def generate_next_n_tokens(
n: int,
ngram_model: dict[str, dict[str, float]],
prompt: str,
num_tokens_to_generate: int,
) -> str:
"""Генерирует `num_tokens_to_generate` токенов после промпта с помощью
n-граммной языковой модели.
Функция берёт n-граммную модель и предсказывает с её помощью следующий
токен для промпта. Генерация идёт итеративно: предсказанные токены
дописываются к промпту, пока не наберётся нужное число токенов или пока
не встретится контекст, для которого у модели нет предсказаний.
Args:
n: Размер n-грамм (например, 2 для биграммной модели).
ngram_model: Словарь — n-граммная языковая модель.
prompt: Начальный промпт, с которого начинается генерация.
num_tokens_to_generate: Сколько слов сгенерировать после промпта.
Returns:
Строка: исходный промпт и сгенерированные токены. Если для какого-то
контекста продолжение не найдено, функция возвращает текст,
сгенерированный до этого места, и печатает об этом сообщение.
"""
# Разбиваем промпт на отдельные токены.
generated_words = space_tokenize(prompt)
for _ in range(num_tokens_to_generate):
# Берём последние (n-1) токенов как контекст.
context = generated_words[-(n - 1):]
context = " ".join(context)
if context in ngram_model:
# Сэмплируем следующее слово по вероятностям.
next_word = random.choices(
list(ngram_model[context].keys()),
weights=ngram_model[context].values()
)[0]
generated_words.append(next_word)
else:
print(
"⚠️ No valid continuation found. Change the prompt or"
" try sampling another continuation.\n"
)
break
return " ".join(generated_words)

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

prompt = "Jide was hungry so she went looking for"
# Строим биграммную модель по набору Africa Galore.
bigram_model = build_ngram_model(dataset, n=2)
n = 2 # Биграммы.
num_tokens_to_generate = 10 # Сколько следующих слов сгенерировать.
generate_next_n_tokens(
n=n,
ngram_model=bigram_model,
prompt=prompt,
num_tokens_to_generate=num_tokens_to_generate,
)

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

prompt = "Jide was hungry so she went looking for"
n = 3 # Триграммы.
num_tokens_to_generate = 10 # Сколько следующих слов сгенерировать.
generate_next_n_tokens(
n=n,
ngram_model=trigram_model,
prompt=prompt,
num_tokens_to_generate=num_tokens_to_generate,
)

Результаты разных запусков различаются, потому что n-граммная модель стохастическая: она сэмплирует следующий токен из распределения. У более вероятных слов больше шансов быть выбранными, но выбор не гарантирован.

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

Интуитивно кажется, что более длинный контекст (больше nn) должен давать лучший текст, улавливая более дальние зависимости в языке. Но такая модель быстро упирается в разреженность данных: большинство n-грамм в наборе никогда не встретится.

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

Возьмите простой словарь из пяти токенов: «I», «love», «to», «eat» и «jollof» («я», «люблю», «—», «есть», «джоллоф»). Возможных биграмм не больше

5×5=25.5 \times 5 = 25.

Но в реальных данных, скорее всего, встретятся только частые пары вроде «I love», «love to», «to eat» и «eat jollof». У триграмм (троек токенов) возможных сочетаний

5×5×5=125,5 \times 5 \times 5 = 125,

но в данных появятся лишь немногие из них, например «I love to», «love to eat» и «to eat jollof».

Даже на огромных наборах данных многие n-граммы высоких порядков в корпусе не появятся никогда. Отсюда много нулевых счётчиков в вероятностях. С ростом порядка n-грамм число возможных сочетаний растёт экспоненциально; многие из них оказываются редкими или отсутствуют в данных, и надёжно оценить вероятности становится всё труднее.

Это конец лабораторной «Эксперименты с n-граммными моделями».

Лабораторная дала практическое знакомство с n-граммными языковыми моделями. Главные выводы:

1. Как это работает.

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

2. Разреженность данных.

  • Разреженность — главная трудность n-граммных моделей, особенно высоких порядков (триграммы и больше).
  • Она возникает потому, что многие возможные сочетания токенов в реальных текстах редки или отсутствуют.
  • Вы видели это на наборе данных: таблица триграмм гораздо больше таблицы биграмм, и нулей в ней больше.

3. Случайность и генерация текста.

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

4. Что учитывать при генерации текста.

  • Размер n влияет на качество текста. Большее n может улавливать более дальние зависимости, но ведёт к разреженности и повторяющемуся тексту.
  • Модель не может продолжить текст после n-граммы, которой нет в наборе данных.

В следующем материале вы поразмышляете об ограничениях n-граммных моделей и сравните их с более продвинутыми моделями.

Задание 1
# Полная реализация `generate_ngrams`.
def generate_ngrams(text: str, n: int) -> list[tuple[str]]:
"""Строит n-граммы для данного текста.
Args:
text: Входная строка.
n: Размер n-грамм (например, 2 для биграмм, 3 для триграмм).
Returns:
Список n-грамм, каждая из которых представлена кортежем токенов.
"""
# Токенизируем текст.
tokens = space_tokenize(text)
# Строим список n-грамм.
ngrams = []
num_of_tokens = len(tokens)
# Последняя n-грамма — tokens[num_of_tokens - n: num_of_tokens].
for i in range(0, num_of_tokens - n + 1):
ngrams.append(tuple(tokens[i:i+n]))
return ngrams
Задание 2
# Полная реализация get_ngram_counts.
def get_ngram_counts(dataset: list[str], n: int) -> dict[str, Counter]:
"""Считает n-граммы в наборе данных (описание — как в задании выше)."""
# defaultdict автоматически создаёт пустой Counter для нового ключа,
# поэтому ngram_counts[context][next_token] можно менять без подготовки.
ngram_counts = defaultdict(Counter)
# Перебираем все абзацы.
for paragraph in dataset:
# Перебираем все n-граммы абзаца.
for ngram in generate_ngrams(paragraph, n):
# Выделяем контекст: все токены, кроме последнего.
context = " ".join(ngram[:-1])
# Выделяем следующий токен: последний токен n-граммы.
next_token = ngram[-1]
# Увеличиваем счётчик пары «контекст — следующий токен» на 1.
ngram_counts[context][next_token] += 1
return dict(ngram_counts)
Задание 3
# Полная реализация build_ngram_model.
def build_ngram_model(dataset: list[str], n: int) -> dict[str, dict[str, float]]:
"""Строит n-граммную языковую модель (описание — как в задании выше)."""
# В ngram_model[context][token] будет лежать P(token | context).
ngram_model = {}
# Берём счётчики, которые вычисляет функция get_ngram_counts.
ngram_counts = get_ngram_counts(dataset, n)
# Перебираем контексты: `context` — строка, `next_tokens` — словарь
# «следующий токен → сколько раз он шёл после `context`».
for context, next_tokens in ngram_counts.items():
# Вычисляем Count(A) и P(B | A).
context_total_count = sum(next_tokens.values())
ngram_model[context] = {}
for token, count in next_tokens.items():
ngram_model[context][token] = count / context_total_count
return ngram_model
Задание 4
# Вставьте этот код, чтобы получить токены-кандидаты и их вероятности.
# Берём из `trigram_model` токены-кандидаты и их вероятности.
for token, prob in trigram_model[context].items():
candidate_tokens.append(token)
candidate_tokens_probabilities.append(prob)

Лабораторная 1.3. Сравните n-граммные модели и трансформерные языковые модели

Заголовок раздела «Лабораторная 1.3. Сравните n-граммные модели и трансформерные языковые модели»

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

Сравните, что генерируют n-граммная модель и трансформерная языковая модель. 30 минут.

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

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

В этой лабораторной вы поэкспериментируете с более продвинутой языковой моделью на основе архитектуры трансформера (transformer). Трансформер — пример нейросетевой модели, то есть класса сложных моделей машинного обучения, которые умеют выучивать из данных очень сложные закономерности. Трансформеры лежат в основе современных больших языковых моделей. Такие модели намного лучше n-граммных порождают связные ответы на произвольные промпты.

Вы убедитесь в этом сами, сравнив генерации своей n-граммной модели с генерациями трансформера.

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

  • чем различаются распределения вероятностей, которые предсказывают n-граммные модели и трансформеры;
  • чем различаются генерации на основе этих распределений.

В этой лабораторной не нужно писать новый код: вы будете работать с двумя языковыми моделями — трансформером Gemma-1B и триграммной моделью.

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

  • загрузите трансформер Gemma-1B и триграммную модель из предыдущей лабораторной;
  • посмотрите, как различаются у двух моделей распределения вероятностей над следующим токеном;
  • исследуете, чем различаются генерации двух моделей.

В этой лабораторной вы в основном работаете с пакетом ai_foundations, написанным специально для курса. «Под капотом» он использует пакет gemma, чтобы загружать модель Gemma-1B и отправлять ей промпты, и пакет plotly для визуализаций.

%%capture
!pip install jax[cuda12]==0.7.2
!pip install "git+https://github.com/google-deepmind/ai-foundations.git@main"
# Используемые пакеты.
import os # Чтобы задать переменную, нужную для загрузки модели на GPU.
import pandas as pd # Для загрузки набора Africa Galore.
# Функции для очистки вывода и форматирования.
from IPython.display import clear_output, display, HTML
# Функции для генерации текстов языковой моделью, визуализации распределений
# вероятностей и загрузки n-граммной модели.
from ai_foundations import generation
from ai_foundations import visualizations
from ai_foundations.ngram import model as ngram_model
# Разрешаем JAX использовать всю память GPU.
os.environ["XLA_PYTHON_CLIENT_MEM_FRACTION"] = "1.00"

Чтобы подготовиться к сравнению n-граммной модели и трансформера, следующая ячейка загружает набор Africa Galore и создаёт триграммную модель, вероятности которой оценены по счётчикам n-грамм этого набора. Ещё она загружает модель Gemma-1B.

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

# Загружаем набор 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.\n")
# Загружаем триграммную модель, вероятности которой оценены
# по набору Africa Galore.
trigram_model = ngram_model.NGramModel(dataset, 3)
print("Loaded trigram model.\n")
print("Loading Gemma-1B model...")
gemma_model = generation.load_gemma()
print("Loaded Gemma-1B model.")

Модели загружены, и можно сравнивать их генерации. Обычно модели оценивают по множеству критериев, и какие из них важнее всего, зависит от решаемой задачи. Например, если вы разрабатываете информационного чат-бота, важно, чтобы модель давала точные сведения. А если модель нужна для творческих задач, важнее может оказаться разнообразие генераций и отсутствие повторов.

В этой лабораторной сосредоточьтесь на таких критериях:

  1. Беглость (fluency): читается ли текст естественно? Грамматические ошибки, например, снижают беглость. Даже грамматически правильные предложения трудно понять, если они тянутся бесконечно.
  2. Связность (coherence): логичен ли текст и держится ли темы? Языковая модель предсказывает по одному токену, поэтому конец генерации может оказаться совсем о другом, чем начало.
  3. Уместность (relevance): соответствует ли ответ контексту или промпту? Модель может выдать набор случайных на вид токенов, который не является ответом.
  4. Предвзятость (bias): поддерживает ли ответ неравенство? Языковые модели обучаются на текстах, написанных людьми, а в них наверняка есть предубеждения и стереотипы. В генерациях модели могут встретиться очень стереотипные ответы, которые закрепляют неравенство.

Для начала сгенерируйте один токен для промпта «Jide was hungry so she went looking for». Затем меняйте промпт и смотрите, как меняются предсказания. Чтобы сгенерировать токен, впишите промпт и запустите ячейку ниже.

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

Учтите, что токены многих трансформеров — это иногда один символ или часть слова. Поэтому генерация может оборваться на токене, который является лишь началом слова.

# Вычисляем следующий токен для промпта.
prompt = "Jide was hungry so she went looking for" # @param {type: "string"}
output_text_transformer, _, _ = (
generation.prompt_transformer_model(
prompt, max_new_tokens=1, loaded_model=gemma_model
)
)
clear_output()
print(f"Generation by Gemma-1B:\n{output_text_transformer}\n\n")
output_text_ngram = trigram_model.generate(1, prompt)
print(f"Generation by trigram model:\n{output_text_ngram}")

Визуализируем распределение над следующим токеном

Заголовок раздела «Визуализируем распределение над следующим токеном»

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

Запустите следующую ячейку: она построит распределения следующего токена для промпта ниже. Каждый столбик графика — отдельный токен, его высота — вероятность, которую присвоила ему модель. Чем выше столбик, тем вероятнее модель выберет этот токен при генерации.

Чтобы графики были компактнее, на них показаны только 30 токенов с наибольшими вероятностями. Трансформер присваивает ненулевую вероятность гораздо большему числу токенов, поэтому вероятности на графике, скорее всего, в сумме не дадут 1.

Запустите ячейку и рассмотрите распределение. Получится ли беглый текст, если выбирать любые из этих токенов? Разумно ли распределены вероятности? Повторите для нескольких разных промптов, чтобы понять, как распределения триграммной модели соотносятся с распределениями Gemma-1B.

# Визуализируем распределения вероятностей.
prompt = "Jide was hungry so she went looking for" # @param {type: "string"}
output_text_transformer, next_token_logits, tokenizer = (
generation.prompt_transformer_model(
prompt, max_new_tokens=1, loaded_model=gemma_model
)
)
display(HTML("<h3>Gemma-1B</h3>"))
# Рисуем вероятности Gemma-1B.
visualizations.plot_next_token(
next_token_logits,
prompt=prompt,
tokenizer=tokenizer
)
display(HTML("<h3>Trigram model</h3>"))
# Рисуем вероятности триграммной модели.
context_ngram = tuple(prompt.split(" ")[-2:])
if context_ngram in trigram_model.probabilities:
visualizations.plot_next_token(
trigram_model.probabilities[context_ngram], prompt=prompt
)
else:
print(
"The trigram model does not make any predictions for the prompt"
f" \"{prompt}\" since the bigram \"{' '.join(context_ngram)}\""
f" is not part of the dataset."
)

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

Вот что вы, скорее всего, заметите:

  1. Модель Gemma присваивает вероятности большему числу токенов, чем триграммная (которая для многих промптов вообще не может присвоить вероятности следующему токену).
  2. Самый вероятный токен обычно — частое слово, подходящее к контексту (например, «food» после «Jide was hungry so she went looking for»).
  3. Модель может предлагать слова, которые выглядят правдоподобно, но несут мало информации, вроде «a» или «something».
  4. У некоторых токенов вероятности низкие: модель считает, что они подходят хуже, но не исключает их полностью, например «work», «help» или «Banku» (банку — ганское блюдо из кукурузного и маниокового теста).

Исследуем чувствительность моделей к контексту

Заголовок раздела «Исследуем чувствительность моделей к контексту»

Что происходит с распределением при изменении контекста? Сгенерируйте следующий токен для промпта «Jide was thirsty so she went looking for» и рассмотрите и генерацию, и распределение. Затем сравните распределения с исходным промптом «Jide was hungry so she went looking for». У какой модели распределения меняются сильнее?

# Предсказываем следующий токен и рисуем распределения.
prompt = "Jide was thirsty so she went looking for" # @param {type: "string"}
output_text_transformer, next_token_logits, tokenizer = (
generation.prompt_transformer_model(
prompt, max_new_tokens=1, loaded_model=gemma_model
)
)
output_text_ngram = trigram_model.generate(1, prompt)
clear_output()
print(f"Generation by Gemma-1B:\n{output_text_transformer}\n\n")
output_text_ngram = trigram_model.generate(1, prompt)
print(f"Generation by trigram model:\n{output_text_ngram}")
display(HTML("<h3>Gemma-1B</h3>"))
# Рисуем вероятности Gemma-1B.
visualizations.plot_next_token(next_token_logits, prompt=prompt, tokenizer=tokenizer)
display(HTML("<h3>Trigram model</h3>"))
# Рисуем вероятности триграммной модели.
context_ngram = tuple(prompt.split(" ")[-2:])
if context_ngram in trigram_model.probabilities:
visualizations.plot_next_token(
trigram_model.probabilities[context_ngram], prompt=prompt
)
else:
print(
"The trigram model does not make any predictions for the prompt"
f" \"{prompt}\" since the bigram \"{' '.join(context_ngram)}\""
f" is not part of the dataset."
)

Запуская трансформер с промптами вроде "Jide was thirsty so she went looking for", вы, вероятно, заметите закономерности в предсказанных токенах. Например, чаще будут предлагаться слова, связанные с питьём, вроде «water». Это потому, что трансформер чувствителен к контексту: по всему промпту он связывает токены про голод с токенами вроде «food», а токены про жажду — с токенами вроде «water».

Распределение следующего токена у триграммной модели, наоборот, не изменилось вовсе. Её предсказания опираются только на последнюю биграмму «looking for», а она в обоих промптах одинакова.

Это показывает ключевой недостаток n-граммных моделей: у них очень короткое контекстное окно, и они не могут учесть информацию, которая стоит не в самом конце контекста. У трансформеров же контекстное окно обычно составляет сотни или тысячи токенов, поэтому их ответы гораздо лучше учитывают контекст.

До сих пор вы предсказывали один токен. Но обычно языковые модели применяют, чтобы предсказывать целые последовательности. В этом задании вы сравните ответы Gemma-1B и триграммной модели при генерации длинных последовательностей.

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

  1. Беглость.
  2. Связность.
  3. Уместность.
  4. Предвзятость.
# Генерируем последовательности.
prompt = "Jide was hungry so she went looking for" # @param {type: "string"}
num_tokens_to_generate = 50 # @param {type: "number"}
(output_text_transformer, next_token_logits, tokenizer) = (
generation.prompt_transformer_model(
prompt, max_new_tokens=num_tokens_to_generate, loaded_model=gemma_model
)
)
clear_output()
print(f"Generation by Gemma-1B:\n{output_text_transformer}\n\n")
output_text_ngram = trigram_model.generate(num_tokens_to_generate, prompt)
print(f"Generation by trigram model:\n{output_text_ngram}")

Скорее всего, вы заметили что-то из этого:

  1. Беглость. Gemma-1B обычно гораздо беглее триграммной модели и порождает текст по правилам английского языка. Триграммная модель склонна выдавать последовательности, где короткие фразы звучат гладко, но в целом много ошибок. Причина опять в маленьком контекстном окне n-граммной модели.
  2. Связность. Gemma-1B хоть и не идеальна, но порождает более связные тексты, чем триграммная модель. У обеих моделей иногда бывают бессмысленные генерации или уход от темы, но у трансформеров вроде Gemma это проблема гораздо меньшего масштаба.
  3. Уместность. Триграммная модель редко способна дать уместный ответ. Особенно это заметно, если промпт — вопрос. По этому критерию Gemma-1B снова намного лучше.
  4. Предвзятость. Языковые модели обычно страдают от тех же предубеждений, что есть в их обучающих данных. Например, если сравнить несколько генераций для промптов «The nurse went to university in Ethiopia.» («Медсестра училась в университете в Эфиопии.») и «The doctor went to university in Ethiopia.» («Врач учился в университете в Эфиопии.»), вы, вероятно, увидите, что о медсестре Gemma-1B чаще продолжает с местоимениями женского рода вроде «she» или «her», а о враче — с местоимениями мужского рода вроде «he» и «him». Дело в том, что текстов о врачах-мужчинах и медсёстрах-женщинах гораздо больше, чем наоборот. Модели, обученные на общедоступных текстах, скорее всего, выучивают и такие нежелательные закономерности. Триграммная модель склонна к тем же предубеждениям, но поскольку связных ответов она почти не даёт, на практике ею пользоваться невозможно (вы могли заметить, что на оба этих промпта она продолжает про кофе).

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

Иногда нужно сделать ответ детерминированным и всегда выбирать токен с наибольшей вероятностью. Это называется жадным сэмплированием (greedy sampling; в уроках ChinaAI — жадное декодирование).

Следующая ячейка позволяет переключаться между случайным и жадным сэмплированием. В зависимости от того, поставите ли вы sampling_mode в greedy или в random, вы получите детерминированный или случайный ответ.

Запустите ячейку несколько раз с sampling_mode, равным random, а затем с greedy. В режиме random ответы почти каждый раз должны различаться, в режиме greedy повторный запуск всегда должен давать тот же ответ.

# Случайные и детерминированные генерации.
prompt = "Jide was thirsty so she went looking for" # @param {type: "string"}
num_tokens_to_generate = 50 # @param {type: "number"}
sampling_mode = "random" # @param {type: "string", values:["random", "greedy"]}
(output_text_transformer, next_token_logits, tokenizer) = (
generation.prompt_transformer_model(
prompt,
max_new_tokens=num_tokens_to_generate,
loaded_model=gemma_model,
sampling_mode=sampling_mode,
)
)
clear_output()
print(f"Generation by Gemma-1B:\n{output_text_transformer}\n\n")
output_text_ngram = trigram_model.generate(
num_tokens_to_generate, prompt, sampling_mode=sampling_mode
)
print(f"Generation by trigram model:\n{output_text_ngram}")

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

Разным приложениям нужен разный баланс. Для творческих задач вроде сочинения историй идеально сэмплирование из распределения: модель перебирает разные варианты и выдаёт более изобретательные результаты.

Если для вашей задачи важны точность, последовательность и надёжность, лучше всегда выбирать самый вероятный токен. Существуют и методы, которые позволяют найти баланс между двумя подходами. О них вы узнаете в следующих курсах (в ChinaAI — урок «Генерация текста и сэмплирование»: температура, top-k, top-p).

Вы напрямую сравнили генерации триграммной модели и трансформера и увидели много различий: в беглости, связности и уместности. N-граммная модель часто выдавала «словесный салат» или вовсе не могла продолжить текст, а трансформер в целом порождал вполне разумные ответы (хотя иногда и не идеальные).

Заметим, что сравнение было нечестным по отношению к n-граммной модели. Разница между триграммной моделью и Gemma-1B — не только в устройстве. Gemma-1B обучалась на очень большом наборе данных, а триграммная модель — только на абзацах Africa Galore. Тем не менее, даже если бы n-граммную модель обучили на столь же большом объёме данных, трансформер всё равно работал бы намного лучше.

Тому две главные причины:

  • У трансформеров гораздо больше контекстное окно, поэтому они учитывают информацию токенов, стоящих далеко от генерируемого. У n-граммных моделей контекстное окно — всего n−1n-1 токен; триграммная модель смотрела только на два последних токена.
  • Трансформеры построены на нейросетях, которые способны выучивать сложные и абстрактные закономерности. Как вы узнаете в следующих курсах, нейросети могут выучить, например, что food и snack близки по смыслу. Это позволяет модели отвлекаться от конкретных слов и выучивать более общие закономерности языка, а значит — порождать более разнообразные и связные ответы.

Это конец лабораторной «Сравните n-граммные модели и трансформерные языковые модели».

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

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

[1] Ronen Eldan and Yuanzhi Li. 2023. TinyStories: How Small Can Language Models Be and Still Speak Coherent English? arXiv:2305.07759.

[2] Kamath et al. (Gemma Team). 2025. Gemma 3 Technical Report. Google DeepMind, London. arXiv:2503.19786.

EnglishРусский
language modelязыковая модель
promptпромпт
contextконтекст
candidate words / tokensслова-кандидаты / токены-кандидаты
samplingсэмплирование
greedy samplingжадное сэмплирование, жадное декодирование
stochasticстохастический, случайный
n-gram, unigram, bigram, trigramn-грамма, униграмма, биграмма, триграмма
tokenization, space tokenizerтокенизация, токенизатор по пробелам
text corpus, datasetкорпус, набор данных
sparsityразреженность
context windowконтекстное окно
parametersпараметры
fluency, coherence, relevance, biasбеглость, связность, уместность, предвзятость
context-aware, context-sensitiveучитывающий контекст, чувствительный к контексту