Модуль 2. Энтропия
Сначала попробуйте решить упражнение. Ответы, подробные решения и проектные ориентиры раскрываются отдельно и сохраняют своё состояние при переключении RU/EN.
Теоретические
Скачать файлы курса (.zip) 33,9 КБАрхив содержит девять Python-файлов решений, общих для RU/EN, с сохранением структуры каталогов.
Упражнение 1. Бинарная энтропия
Для
покажите, что:
-
и бит;
-
при
-
строго вогнута и достигает единственного максимума в .
Показать ответ и решение
Напомним:
1. Симметрия и значение в
При замене два слагаемых просто меняются местами, поэтому
Кроме того,
2. Первая производная
Для
а
Постоянные сокращаются:
Единственная стационарная точка удовлетворяет
то есть .
3. Строгая вогнутость и максимум
Следовательно, строго вогнута. Поэтому найденная стационарная точка — единственный максимум. Его значение равно 1 биту.
На границе, по непрерывности и соглашению ,
Упражнение 2. Нечестная кость и точный префиксный код
Пусть
Посчитайте и сравните с энтропией честной шестигранной кости. Можно ли построить двоичный префиксный код со средней длиной ровно бит на бросок? Если да, приведите его.
Показать ответ и решение
Распределение равно
Все вероятности диадические: . Поэтому surprisals — целые числа.
| исход | вклад | ||
|---|---|---|---|
| 1 | 1 | ||
| 2 | 2 | ||
| 3 | 3 | ||
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | 5 |
Отсюда
Для честной шестигранной кости
Смещение вероятностей уменьшило неопределённость примерно на бита за бросок.
Точный двоичный префиксный код можно выбрать так:
| исход | кодовое слово | длина |
|---|---|---|
| 1 | 0 | 1 |
| 2 | 10 | 2 |
| 3 | 110 | 3 |
| 4 | 1110 | 4 |
| 5 | 11110 | 5 |
| 6 | 11111 | 5 |
Ни одно слово не является префиксом другого. Средняя длина равна
Почему равенство достижимо точно? Здесь целочисленны, а неравенство Крафта выполняется с равенством:
Для общего, недиадического распределения однобуквенный двоичный префиксный код обычно не достигает в точности; к этому вернёмся в Модуле 6.
Упражнение 3. Условление может локально увеличить энтропию
Пусть
а условное распределение задано так:
-
Найдите , и .
-
Найдите маргинальное распределение и .
-
Проверьте одновременно и
-
Используя цепное правило и результат §2.2, выведите субаддитивность и условие равенства.
Вычислительное
Показать ответ и решение
Дано
причём при величина — честный Bernoulli, а при всегда равна нулю.
1. Условные энтропии
поскольку условное распределение равномерно на двух исходах, и
поскольку исход детерминирован.
Средняя условная энтропия:
2. Маргинальное распределение
Единица возникает только при , после чего — с вероятностью :
Поэтому
3. Два неравенства одновременно
В среднем условление уменьшило энтропию:
Но для конкретного события произошло обратное:
Именно поэтому в теореме нельзя бездумно заменять среднюю условную энтропию на .
4. Субаддитивность
По цепному правилу
Поскольку ,
Равенство выполняется тогда и только тогда, когда
а по доказанному в §2.2 это эквивалентно независимости и .
Упражнение 4. Что на самом деле показывает -граммная оценка
Возьмите Alice's Adventures in Wonderland с Project Gutenberg. Приведите текст к алфавиту из 27 символов: a–z и пробел; переведите в нижний регистр, замените остальные символы пробелами и схлопните повторные пробелы.
Для контекста длины и следующего символа обозначьте эмпирические частоты через и . Посчитайте подстановочную оценку (plug-in estimate)
для .
Затем разделите текст по времени: первые 80% — обучающая часть (train), последние 20% — тестовая (test). Обучите -граммную модель со сглаживанием add- на обучающей части, например с , и измерьте кросс-энтропию на отложенной тестовой части (held-out cross-entropy) для .
Ответьте на два вопроса:
-
Почему оценка на тех же данных, по которым построена модель (in-sample plug-in), при большом быстро падает почти к нулю — и почему это не означает, что энтропийная скорость английского текста почти нулевая?
-
Почему кросс-энтропия на отложенной части сначала уменьшается, а затем может расти?
Показать ответ и решение
Ниже — воспроизводимый вариант решения. Он делает две разные оценки, которые важно не смешивать:
-
подстановочную условную энтропию (
plug-in conditional entropy) на той же выборке, по которой посчитаны частоты; -
кросс-энтропию на отложенной части (held-out cross-entropy) модели, обученной на первых 80% текста и проверенной на последних 20%.
from __future__ import annotations
import hashlib
import math
import re
from collections import Counter
from dataclasses import dataclass
import requests
URL = "https://www.gutenberg.org/files/11/11-0.txt"
ALPHABET = "abcdefghijklmnopqrstuvwxyz "
ALPHABET_SIZE = len(ALPHABET)
REFERENCE_TEXT_SHA256 = "c7172a25460df6168670a3ac6c778482feb23546fabf24d05ba9d19dbb9256c8"
def download_and_normalize(url: str = URL) -> str:
"""Download Project Gutenberg text and map it to a 27-symbol alphabet."""
response = requests.get(url, timeout=30)
response.raise_for_status()
raw = response.content.decode("utf-8")
# Remove the Project Gutenberg wrapper while tolerating minor header changes.
start = re.search(
r"\*\*\*\s*START OF THE PROJECT GUTENBERG EBOOK.*?\*\*\*",
raw,
flags=re.IGNORECASE,
)
end = re.search(
r"\*\*\*\s*END OF THE PROJECT GUTENBERG EBOOK.*?\*\*\*",
raw,
flags=re.IGNORECASE,
)
if start is not None:
raw = raw[start.end() :]
if end is not None:
# If the start wrapper was removed, search again in the shortened string.
end_after_start = re.search(
r"\*\*\*\s*END OF THE PROJECT GUTENBERG EBOOK.*?\*\*\*",
raw,
flags=re.IGNORECASE,
)
if end_after_start is not None:
raw = raw[: end_after_start.start()]
text = re.sub(r"[^a-z]+", " ", raw.lower())
text = re.sub(r" +", " ", text).strip()
if not text:
raise ValueError("Normalization produced an empty text")
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
print(f"Normalized corpus SHA-256: {digest}")
if digest != REFERENCE_TEXT_SHA256:
raise ValueError("Corpus differs from the reference input; compare the text before comparing tables")
return text
def plugin_conditional_entropy(text: str, k: int) -> float:
"""Empirical H(next symbol | context of length k), evaluated in-sample."""
if not 0 <= k < len(text):
raise ValueError("k must satisfy 0 <= k < len(text)")
pair_counts: Counter[tuple[str, str]] = Counter()
context_counts: Counter[str] = Counter()
for i in range(k, len(text)):
context = text[i - k : i] if k else ""
symbol = text[i]
pair_counts[(context, symbol)] += 1
context_counts[context] += 1
total = len(text) - k
return -sum(
(count / total) * math.log2(count / context_counts[context])
for (context, _symbol), count in pair_counts.items()
)
@dataclass(frozen=True)
class NGramCounts:
pair: Counter[tuple[str, str]]
context: Counter[str]
def fit_ngram(text: str, k: int) -> NGramCounts:
"""Count context/next-symbol pairs in the training prefix."""
pair_counts: Counter[tuple[str, str]] = Counter()
context_counts: Counter[str] = Counter()
for i in range(k, len(text)):
context = text[i - k : i] if k else ""
symbol = text[i]
pair_counts[(context, symbol)] += 1
context_counts[context] += 1
return NGramCounts(pair=pair_counts, context=context_counts)
def heldout_cross_entropy(
full_text: str,
split: int,
k: int,
alpha: float = 0.1,
) -> float:
"""Score test targets using an add-alpha model fitted only on the prefix."""
if not 0 < split < len(full_text):
raise ValueError("split must lie strictly inside the text")
if not 0 <= k <= split:
raise ValueError("k must satisfy 0 <= k <= split")
if alpha <= 0:
raise ValueError("alpha must be positive")
train = full_text[:split]
counts = fit_ngram(train, k)
nll_bits = 0.0
n_targets = 0
# Every scored target lies in test. For the first k test targets, the context
# may cross the train/test boundary; this is legitimate chronological context.
for i in range(split, len(full_text)):
context = full_text[i - k : i] if k else ""
symbol = full_text[i]
numerator = counts.pair[(context, symbol)] + alpha
denominator = counts.context[context] + alpha * ALPHABET_SIZE
probability = numerator / denominator
nll_bits -= math.log2(probability)
n_targets += 1
if n_targets == 0:
raise ValueError("The test split contains no targets")
return nll_bits / n_targets
text = download_and_normalize()
print(f"Normalized length: {len(text):,} characters")
print("\nIn-sample plug-in conditional entropy")
for k in range(10):
value = plugin_conditional_entropy(text, k)
print(f"k={k:2d}: {value:.4f} bits/character")
split = int(0.8 * len(text))
print("\nHeld-out add-0.1 cross-entropy")
for k in range(8):
value = heldout_cross_entropy(text, split, k, alpha=0.1)
print(f"k={k:2d}: {value:.4f} bits/character")Для версии файла, доступной во время проверки модуля, после удаления Gutenberg-wrapper и указанной нормализации получилось символов.
Подстановочная оценка на тех же данных (in-sample)
| длина контекста | бит/символ |
|---|---|
| 0 | 4.0458 |
| 1 | 3.2098 |
| 2 | 2.4446 |
| 3 | 1.8240 |
| 4 | 1.4157 |
| 5 | 1.0881 |
| 6 | 0.8365 |
| 7 | 0.6142 |
| 8 | 0.4367 |
| 9 | 0.3132 |
Кросс-энтропия add- на отложенной части
| длина контекста | бит/символ |
|---|---|
| 0 | 4.0576 |
| 1 | 3.2143 |
| 2 | 2.5408 |
| 3 | 2.1391 |
| 4 | 2.1653 |
| 5 | 2.4272 |
| 6 | 2.8067 |
| 7 | 3.1954 |
Последние цифры могут немного измениться, если Project Gutenberg обновит файл, если иначе обрезать служебную часть или изменить разбиение и сглаживание (split/smoothing). Важна не последняя десятичная цифра, а качественная картина.
Почему кривая на обучающих данных почти падает к нулю
При большом большинство длинных контекстов встречается один раз. Для такого контекста эмпирическая модель видит ровно один следующий символ и присваивает ему условную вероятность 1. Его вклад в условную энтропию на обучающих данных равен
Когда почти все контексты становятся уникальными, гибкая таблица частот просто запоминает выборку. Это оптимистическая оценка на тех же данных (in-sample), а не свидетельство того, что будущий английский текст почти детерминирован.
Здесь полезно помнить два связанных факта:
- обычный подстановочный оцениватель дискретной энтропии при фиксированном алфавите в среднем имеет отрицательное смещение (bias);
- при растущем пространстве контекстов проблема становится намного сильнее: одна и та же выборка используется и для подгонки условных распределений, и для их оценки.
Иными словами, ноль здесь говорит прежде всего о способности таблицы -грамм запоминать данные, а не об энтропийной скорости источника.
Почему кривая на отложенных данных сначала падает, а затем растёт
При малых дополнительный контекст снимает реальные зависимости: после q буква u намного вероятнее, чем без контекста. Ошибка аппроксимации уменьшается, и тестовая кросс-энтропия падает.
Но число возможных контекстов растёт как . При фиксированной обучающей выборке многие контексты становятся редкими или не встречаются вовсе. Оценки получают высокую дисперсию, а сглаживание add- отдаёт всё больше массы не тем символам. Ошибка оценивания перевешивает пользу дополнительного контекста — кросс-энтропия на отложенных данных начинает расти.
Это классический компромисс смещения и дисперсии (bias–variance trade-off) в дискретной форме. Более качественное сглаживание, откат (backoff), интерполяция или модель, умеющая делиться статистической силой между похожими контекстами, сдвинут минимум вправо и вниз, но не отменят необходимость оценки на отложенных данных.
Бонус
Оцените тот же фрагмент с GPT-2 или другой авторегрессионной языковой моделью (causal LM) при помощи скользящего окна. Переведите суммарный NLL в биты на токен (bits per token) и биты на символ (bits per character). Не пытайтесь заранее попасть в «магическое» число: зафиксируйте модель, версию текста, правила предварительной обработки, длину контекста и проверьте, сколько целевых токенов действительно вошло в функцию потерь.
Показать ответ и решение
Добавьте этот блок после кода упражнения 4 в тот же .py-файл: ему нужна уже подготовленная строка text.
Ниже код, в котором каждый токен исходного текста входит в функцию потерь ровно один раз. Это тонкое место: авторегрессионная языковая модель внутри сдвигает метки (labels) на один шаг, поэтому умножать out.loss просто на длину немаскированного хвоста нельзя — нужно считать метки, которые остаются валидными после сдвига.
import math
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "openai-community/gpt2"
MODEL_REVISION = "607a30d783dfa663caf39e06633721c8d4cfcd7e"
SAMPLE_CHARS = 50_000
STRIDE = 512
# `text` is the normalized string from Exercise 4.
sample = text[:SAMPLE_CHARS]
if not sample:
raise ValueError("The sample is empty")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, revision=MODEL_REVISION)
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, revision=MODEL_REVISION).to(device).eval()
text_ids = tokenizer(
sample,
return_tensors="pt",
add_special_tokens=False,
).input_ids
if tokenizer.eos_token_id is None:
raise ValueError("The tokenizer has no EOS token for the initial prefix")
# Prefix EOS lets the model assign probability to the first text token as well.
eos = torch.tensor([[tokenizer.eos_token_id]], dtype=text_ids.dtype)
input_ids = torch.cat([eos, text_ids], dim=1).to(device)
max_length = int(model.config.n_positions)
if not 0 < STRIDE < max_length:
raise ValueError("STRIDE must satisfy 0 < STRIDE < model context length")
nll_sum_nats = 0.0
n_scored_tokens = 0
previous_end = 0
sequence_length = input_ids.size(1)
for begin in range(0, sequence_length, STRIDE):
end = min(begin + max_length, sequence_length)
new_length = end - previous_end
chunk = input_ids[:, begin:end]
labels = chunk.clone()
labels[:, :-new_length] = -100
# Hugging Face causal-LM loss compares logits[:, :-1] with labels[:, 1:].
# Count exactly those shifted labels that are not masked.
n_loss_tokens = int((labels[:, 1:] != -100).sum().item())
if n_loss_tokens > 0:
with torch.no_grad():
output = model(chunk, labels=labels)
nll_sum_nats += float(output.loss.item()) * n_loss_tokens
n_scored_tokens += n_loss_tokens
previous_end = end
if end == sequence_length:
break
expected_scored = text_ids.size(1)
if n_scored_tokens != expected_scored:
raise RuntimeError(
f"Scored {n_scored_tokens} tokens, expected {expected_scored}"
)
bits_per_token = nll_sum_nats / (n_scored_tokens * math.log(2))
bits_per_character = nll_sum_nats / (len(sample) * math.log(2))
perplexity = math.exp(nll_sum_nats / n_scored_tokens)
print(f"Scored tokens: {n_scored_tokens:,}")
print(f"Bits per token: {bits_per_token:.4f}")
print(f"Bits per character: {bits_per_character:.4f}")
print(f"Token perplexity: {perplexity:.4f}")Результат следует документировать вместе с моделью, её версией (revision), файлом текста, нормализацией, длиной контекста и шагом окна (stride). Для Alice есть ещё одна важная оговорка: этот публичный классический текст мог входить в корпус предобучения GPT-2. Поэтому такой эксперимент хорош как учебная демонстрация вычисления кросс-энтропии, но не как чистая оценка обобщающей способности или «истинной энтропии английского языка».