VKВлад КуликовОткрытые лекции
Теория информации для машинного обучения2 / 16

Модуль 2. Энтропия

Сначала попробуйте решить упражнение. Ответы, подробные решения и проектные ориентиры раскрываются отдельно и сохраняют своё состояние при переключении RU/EN.

Теоретические

Скачать файлы курса (.zip) 33,9 КБ

Архив содержит девять Python-файлов решений, общих для RU/EN, с сохранением структуры каталогов.

Упражнение 1. Бинарная энтропия

Для

h2(p)=−plog⁡2p−(1−p)log⁡2(1−p)h_2(p)=-p\log_2p-(1-p)\log_2(1-p)

покажите, что:

  1. h2(p)=h2(1−p)h_2(p)=h_2(1-p) и h2(1/2)=1h_2(1/2)=1 бит;

  2. при 0<p<10<p<1 h2′(p)=log⁡21−pp;h_2'(p)=\log_2\frac{1-p}{p};

  3. h2h_2 строго вогнута и достигает единственного максимума в p=1/2p=1/2.

Показать ответ и решение

Напомним:

h2(p)=−plog⁡2p−(1−p)log⁡2(1−p).h_2(p)=-p\log_2p-(1-p)\log_2(1-p).

1. Симметрия и значение в p=1/2p=1/2

При замене p↦1−pp\mapsto1-p два слагаемых просто меняются местами, поэтому

h2(p)=h2(1−p).h_2(p)=h_2(1-p).

Кроме того,

h2 ⁣(12)=−12log⁡212−12log⁡212=12+12=1 бит.\begin{aligned} h_2\!\left(\frac12\right) &=-\frac12\log_2\frac12-\frac12\log_2\frac12\\ &=\frac12+\frac12=1\ \text{бит}. \end{aligned}

2. Первая производная

Для 0<p<10<p<1

ddp[−plog⁡2p]=−log⁡2p−1ln⁡2,\frac{d}{dp}\bigl[-p\log_2p\bigr] =-\log_2p-\frac1{\ln2},

а

ddp[−(1−p)log⁡2(1−p)]=log⁡2(1−p)+1ln⁡2.\frac{d}{dp}\bigl[-(1-p)\log_2(1-p)\bigr] =\log_2(1-p)+\frac1{\ln2}.

Постоянные сокращаются:

h2′(p)=log⁡2(1−p)−log⁡2p=log⁡21−pp.h_2'(p)=\log_2(1-p)-\log_2p =\log_2\frac{1-p}{p}.

Единственная стационарная точка удовлетворяет

1−pp=1,\frac{1-p}{p}=1,

то есть p=1/2p=1/2.

3. Строгая вогнутость и максимум

h2′′(p)=−1(1−p)ln⁡2−1pln⁡2=−1p(1−p)ln⁡2<0,0<p<1.\begin{aligned} h_2''(p) &=-\frac1{(1-p)\ln2}-\frac1{p\ln2}\\ &=-\frac1{p(1-p)\ln2}<0, \qquad 0<p<1. \end{aligned}

Следовательно, h2h_2 строго вогнута. Поэтому найденная стационарная точка — единственный максимум. Его значение равно 1 биту.

На границе, по непрерывности и соглашению 0log⁡0=00\log0=0,

h2(0)=h2(1)=0.h_2(0)=h_2(1)=0.

Упражнение 2. Нечестная кость и точный префиксный код

Пусть

p=(12,14,18,116,132,132).p=\left(\frac12,\frac14,\frac18,\frac1{16},\frac1{32},\frac1{32}\right).

Посчитайте H(X)H(X) и сравните с энтропией честной шестигранной кости. Можно ли построить двоичный префиксный код со средней длиной ровно H(X)H(X) бит на бросок? Если да, приведите его.

Показать ответ и решение

Распределение равно

p=(12,14,18,116,132,132).p=\left(\frac12,\frac14,\frac18,\frac1{16},\frac1{32},\frac1{32}\right).

Все вероятности диадические: pi=2−ℓip_i=2^{-\ell_i}. Поэтому surprisals −log⁡2pi-\log_2p_i — целые числа.

исходpip_i−log⁡2pi-\log_2p_iвклад −pilog⁡2pi-p_i\log_2p_i
11/21/211/21/2
21/41/421/21/2
31/81/833/83/8
41/161/1641/41/4
51/321/3255/325/32
61/321/3255/325/32

Отсюда

H(X)=16+16+12+8+5+532=3116=1.9375 бита.H(X) =\frac{16+16+12+8+5+5}{32} =\frac{31}{16} =1.9375\ \text{бита}.

Для честной шестигранной кости

Hfair=log⁡26≈2.585 бита.H_{\text{fair}}=\log_2 6\approx2.585\ \text{бита}.

Смещение вероятностей уменьшило неопределённость примерно на 0.6470.647 бита за бросок.

Точный двоичный префиксный код можно выбрать так:

исходкодовое словодлина
101
2102
31103
411104
5111105
6111115

Ни одно слово не является префиксом другого. Средняя длина равна

L=∑ipiℓi=3116=H(X).L=\sum_i p_i\ell_i=\frac{31}{16}=H(X).

Почему равенство достижимо точно? Здесь ℓi=−log⁡2pi\ell_i=-\log_2p_i целочисленны, а неравенство Крафта выполняется с равенством:

∑i2−ℓi=∑ipi=1.\sum_i2^{-\ell_i}=\sum_ip_i=1.

Для общего, недиадического распределения однобуквенный двоичный префиксный код обычно не достигает H(X)H(X) в точности; к этому вернёмся в Модуле 6.

Упражнение 3. Условление может локально увеличить энтропию

Пусть

P(X=0)=0.1,P(X=1)=0.9,P(X=0)=0.1,\qquad P(X=1)=0.9,

а условное распределение YY задано так:

Y∣X=0∼Bernoulli⁡(1/2),Y∣X=1=0 почти наверное.Y\mid X=0\sim\operatorname{Bernoulli}(1/2),\qquad Y\mid X=1=0\ \text{почти наверное}.

  1. Найдите H(Y∣X=0)H(Y\mid X=0), H(Y∣X=1)H(Y\mid X=1) и H(Y∣X)H(Y\mid X).

  2. Найдите маргинальное распределение YY и H(Y)H(Y).

  3. Проверьте одновременно H(Y∣X)≤H(Y)H(Y\mid X)\le H(Y) и H(Y∣X=0)>H(Y).H(Y\mid X=0)>H(Y).

  4. Используя цепное правило и результат §2.2, выведите субаддитивность H(X,Y)≤H(X)+H(Y)H(X,Y)\le H(X)+H(Y) и условие равенства.

Вычислительное

Показать ответ и решение

Дано

P(X=0)=0.1,P(X=1)=0.9,P(X=0)=0.1,\qquad P(X=1)=0.9,

причём при X=0X=0 величина YY — честный Bernoulli, а при X=1X=1 всегда равна нулю.

1. Условные энтропии

H(Y∣X=0)=1 бит,H(Y\mid X=0)=1\ \text{бит},

поскольку условное распределение равномерно на двух исходах, и

H(Y∣X=1)=0,H(Y\mid X=1)=0,

поскольку исход детерминирован.

Средняя условная энтропия:

H(Y∣X)=0.1⋅1+0.9⋅0=0.1 бита.H(Y\mid X) =0.1\cdot1+0.9\cdot0 =0.1\ \text{бита}.

2. Маргинальное распределение YY

Единица возникает только при X=0X=0, после чего — с вероятностью 1/21/2:

P(Y=1)=0.1⋅12=0.05,P(Y=1)=0.1\cdot\frac12=0.05,

P(Y=0)=0.95.P(Y=0)=0.95.

Поэтому

H(Y)=h2(0.05)=−0.05log⁡20.05−0.95log⁡20.95≈0.2864 бита.H(Y)=h_2(0.05) =-0.05\log_2 0.05-0.95\log_2 0.95 \approx0.2864\ \text{бита}.

3. Два неравенства одновременно

В среднем условление уменьшило энтропию:

H(Y∣X)=0.1<0.2864≈H(Y).H(Y\mid X)=0.1<0.2864\approx H(Y).

Но для конкретного события X=0X=0 произошло обратное:

H(Y∣X=0)=1>0.2864≈H(Y).H(Y\mid X=0)=1>0.2864\approx H(Y).

Именно поэтому в теореме H(Y∣X)≤H(Y)H(Y\mid X)\le H(Y) нельзя бездумно заменять среднюю условную энтропию на H(Y∣X=x)H(Y\mid X=x).

4. Субаддитивность

По цепному правилу

H(X,Y)=H(X)+H(Y∣X).H(X,Y)=H(X)+H(Y\mid X).

Поскольку H(Y∣X)≤H(Y)H(Y\mid X)\le H(Y),

H(X,Y)≤H(X)+H(Y).H(X,Y)\le H(X)+H(Y).

Равенство выполняется тогда и только тогда, когда

H(Y∣X)=H(Y),H(Y\mid X)=H(Y),

а по доказанному в §2.2 это эквивалентно независимости XX и YY.

Упражнение 4. Что на самом деле показывает nn-граммная оценка

Возьмите Alice's Adventures in Wonderland с Project Gutenberg. Приведите текст к алфавиту из 27 символов: a–z и пробел; переведите в нижний регистр, замените остальные символы пробелами и схлопните повторные пробелы.

Для контекста uu длины kk и следующего символа aa обозначьте эмпирические частоты через N(u,a)N(u,a) и N(u)=∑aN(u,a)N(u)=\sum_aN(u,a). Посчитайте подстановочную оценку (plug-in estimate)

h^kplug-in=−∑u,aN(u,a)N−klog⁡2N(u,a)N(u)\widehat h_k^{\text{plug-in}} =-\sum_{u,a}\frac{N(u,a)}{N-k} \log_2\frac{N(u,a)}{N(u)}

для k=0,1,…,9k=0,1,\ldots,9.

Затем разделите текст по времени: первые 80% — обучающая часть (train), последние 20% — тестовая (test). Обучите nn-граммную модель со сглаживанием add-α\alpha на обучающей части, например с α=0.1\alpha=0.1, и измерьте кросс-энтропию на отложенной тестовой части (held-out cross-entropy) для k=0,…,7k=0,\ldots,7.

Ответьте на два вопроса:

  1. Почему оценка на тех же данных, по которым построена модель (in-sample plug-in), при большом kk быстро падает почти к нулю — и почему это не означает, что энтропийная скорость английского текста почти нулевая?

  2. Почему кросс-энтропия на отложенной части сначала уменьшается, а затем может расти?

Показать ответ и решение

Ниже — воспроизводимый вариант решения. Он делает две разные оценки, которые важно не смешивать:

  1. подстановочную условную энтропию (plug-in conditional entropy) на той же выборке, по которой посчитаны частоты;

  2. кросс-энтропию на отложенной части (held-out cross-entropy) модели, обученной на первых 80% текста и проверенной на последних 20%.

Python
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 и указанной нормализации получилось N=135,508N=135{,}508 символов.

Подстановочная оценка на тех же данных (in-sample)

длина контекста kkбит/символ
04.0458
13.2098
22.4446
31.8240
41.4157
51.0881
60.8365
70.6142
80.4367
90.3132

Кросс-энтропия add-0.10.1 на отложенной части

длина контекста kkбит/символ
04.0576
13.2143
22.5408
32.1391
42.1653
52.4272
62.8067
73.1954

Последние цифры могут немного измениться, если Project Gutenberg обновит файл, если иначе обрезать служебную часть или изменить разбиение и сглаживание (split/smoothing). Важна не последняя десятичная цифра, а качественная картина.

Почему кривая на обучающих данных почти падает к нулю

При большом kk большинство длинных контекстов встречается один раз. Для такого контекста эмпирическая модель видит ровно один следующий символ и присваивает ему условную вероятность 1. Его вклад в условную энтропию на обучающих данных равен

−log⁡21=0.-\log_2 1=0.

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

Здесь полезно помнить два связанных факта:

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

Иными словами, ноль здесь говорит прежде всего о способности таблицы nn-грамм запоминать данные, а не об энтропийной скорости источника.

Почему кривая на отложенных данных сначала падает, а затем растёт

При малых kk дополнительный контекст снимает реальные зависимости: после q буква u намного вероятнее, чем без контекста. Ошибка аппроксимации уменьшается, и тестовая кросс-энтропия падает.

Но число возможных контекстов растёт как 27k27^k. При фиксированной обучающей выборке многие контексты становятся редкими или не встречаются вовсе. Оценки получают высокую дисперсию, а сглаживание add-α\alpha отдаёт всё больше массы не тем символам. Ошибка оценивания перевешивает пользу дополнительного контекста — кросс-энтропия на отложенных данных начинает расти.

Это классический компромисс смещения и дисперсии (bias–variance trade-off) в дискретной форме. Более качественное сглаживание, откат (backoff), интерполяция или модель, умеющая делиться статистической силой между похожими контекстами, сдвинут минимум вправо и вниз, но не отменят необходимость оценки на отложенных данных.

Бонус

Оцените тот же фрагмент с GPT-2 или другой авторегрессионной языковой моделью (causal LM) при помощи скользящего окна. Переведите суммарный NLL в биты на токен (bits per token) и биты на символ (bits per character). Не пытайтесь заранее попасть в «магическое» число: зафиксируйте модель, версию текста, правила предварительной обработки, длину контекста и проверьте, сколько целевых токенов действительно вошло в функцию потерь.

Показать ответ и решение

Добавьте этот блок после кода упражнения 4 в тот же .py-файл: ему нужна уже подготовленная строка text.

Ниже код, в котором каждый токен исходного текста входит в функцию потерь ровно один раз. Это тонкое место: авторегрессионная языковая модель внутри сдвигает метки (labels) на один шаг, поэтому умножать out.loss просто на длину немаскированного хвоста нельзя — нужно считать метки, которые остаются валидными после сдвига.

Python
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. Поэтому такой эксперимент хорош как учебная демонстрация вычисления кросс-энтропии, но не как чистая оценка обобщающей способности или «истинной энтропии английского языка».