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

Модуль 6. Кодирование источника: от энтропии к реальным битам

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

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

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

Упражнение 1. Хаффман руками

Постройте код Хаффмана для

P=(0.40,0.20,0.15,0.15,0.10).P=(0.40,0.20,0.15,0.15,0.10).

Вычислите LL, H2(P)H_2(P) и L−H2(P)L-H_2(P). Проверьте неравенство Крафта.

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

Обозначим символы через

a:0.40,b:0.20,c:0.15,d:0.15,e:0.10.a:0.40, \quad b:0.20, \quad c:0.15, \quad d:0.15, \quad e:0.10.

Один из допустимых порядков объединений:

  1. e(0.10)+c(0.15)→N1(0.25)e(0.10)+c(0.15)\to N_1(0.25);

  2. d(0.15)+b(0.20)→N2(0.35)d(0.15)+b(0.20)\to N_2(0.35);

  3. N1(0.25)+N2(0.35)→N3(0.60)N_1(0.25)+N_2(0.35)\to N_3(0.60);

  4. a(0.40)+N3(0.60)→1a(0.40)+N_3(0.60)\to1.

После назначения битов ветвям можно получить код:

СимволВероятностьКодДлина
aa0.4001
ee0.101003
cc0.151013
dd0.151103
bb0.201113

Конкретные нули и единицы можно поменять местами; существенны длины кодовых слов.

Средняя длина:

L=0.40⋅1+(0.20+0.15+0.15+0.10)⋅3=2.20 бит/символ.\begin{aligned} L &=0.40\cdot1 +(0.20+0.15+0.15+0.10)\cdot3\\ &=2.20\ \text{бит/символ}. \end{aligned}

Энтропия:

H2(P)=−∑ipilog⁡2pi≈2.146439 бит/символ.\begin{aligned} H_2(P) &=-\sum_i p_i\log_2p_i\\ &\approx2.146439\ \text{бит/символ}. \end{aligned}

Избыточность:

L−H2(P)≈0.053561 бит/символ.L-H_2(P) \approx0.053561\ \text{бит/символ}.

Проверка Крафта:

2−1+4⋅2−3=12+12=1.2^{-1}+4\cdot2^{-3} =\frac12+\frac12 =1.

Код использует весь бюджет Крафта. Поэтому в разложении

L−H2(P)=DKL,2(P∥Q)−log⁡2ZL-H_2(P) =D_{\mathrm{KL},2}(P\|Q)-\log_2Z

здесь Z=1Z=1, и весь зазор связан с тем, что диадическое распределение, заданное длинами, не совпадает с PP.

Упражнение 2. Когда достигается L=HL=H

Докажите, что односимвольный префиксный код достигает L=H2(P)L=H_2(P) тогда и только тогда, когда распределение диадическое:

pi=2−ki,ki∈N,p_i=2^{-k_i}, \qquad k_i\in\mathbb N,

и соответствующие длины удовлетворяют равенству Крафта.

Постройте пример для

P=(12,14,18,116,132,132).P=\left(\frac12,\frac14,\frac18,\frac1{16},\frac1{32},\frac1{32}\right).
Показать ответ и решение

Для префиксного кода положим

Z=∑i2−ℓi≤1,qi=2−ℓiZ.Z=\sum_i2^{-\ell_i}\le1, \qquad q_i=\frac{2^{-\ell_i}}{Z}.

Из доказательства односимвольной теоремы:

L−H2(P)=DKL,2(P∥Q)−log⁡2Z.L-H_2(P) =D_{\mathrm{KL},2}(P\|Q)-\log_2Z.

Оба слагаемых неотрицательны. Поэтому равенство L=H2(P)L=H_2(P) возможно тогда и только тогда, когда одновременно

DKL,2(P∥Q)=0иZ=1.D_{\mathrm{KL},2}(P\|Q)=0 \quad\text{и}\quad Z=1.

Первое равенство означает P=QP=Q, второе — полное использование бюджета Крафта. Вместе они дают

pi=qi=2−ℓi.p_i=q_i=2^{-\ell_i}.

Поскольку длины ℓi\ell_i целые, все вероятности должны иметь вид

pi=2−ki,ki∈N.p_i=2^{-k_i}, \qquad k_i\in\mathbb N.

Это необходимость. Обратно, если распределение диадическое и

∑i2−ki=1,\sum_i2^{-k_i}=1,

теорема Крафта гарантирует существование префиксного кода с длинами ℓi=ki\ell_i=k_i. Тогда

L=∑ipiki=∑ipi(−log⁡2pi)=H2(P).L=\sum_i p_i k_i =\sum_i p_i(-\log_2p_i) =H_2(P).

Для распределения

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

подходит код:

ИсходВероятностьДлинаКод
11/21/210
21/41/4210
31/81/83110
41/161/1641110
51/321/32511110
61/321/32511111

Проверка:

12+14+18+116+132+132=1,\frac12+\frac14+\frac18+\frac1{16} +\frac1{32}+\frac1{32}=1,

а средняя длина равна

L=12⋅1+14⋅2+18⋅3+116⋅4+2⋅132⋅5=1.9375 бит.L =\frac12\cdot1+\frac14\cdot2+\frac18\cdot3 +\frac1{16}\cdot4+2\cdot\frac1{32}\cdot5 =1.9375\ \text{бит}.

Энтропия имеет то же значение, потому что ℓi=−log⁡2pi\ell_i=-\log_2p_i для каждого исхода.

Упражнение 3. Неверная модель как лишние биты

Пусть длины строятся по QQ:

ℓQ(x)=⌈−log⁡2q(x)⌉.\ell_Q(x)=\lceil-\log_2q(x)\rceil.

Покажите, что

H2(P)+DKL,2(P∥Q)≤LQ<H2(P)+DKL,2(P∥Q)+1.H_2(P)+D_{\mathrm{KL},2}(P\|Q) \le L_Q < H_2(P)+D_{\mathrm{KL},2}(P\|Q)+1.

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

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

Предполагаем, что

q(x)>0q(x)>0

для каждого xx с p(x)>0p(x)>0. Иначе кросс-энтропия и средняя длина бесконечны.

По определению потолка:

−log⁡2q(x)≤⌈−log⁡2q(x)⌉<−log⁡2q(x)+1.-\log_2q(x) \le \left\lceil-\log_2q(x)\right\rceil < -\log_2q(x)+1.

Умножаем на p(x)p(x) и суммируем:

H2(P,Q)≤LQ<H2(P,Q)+1.H_2(P,Q) \le L_Q < H_2(P,Q)+1.

Используем разложение

H2(P,Q)=H2(P)+DKL,2(P∥Q):H_2(P,Q) =H_2(P)+D_{\mathrm{KL},2}(P\|Q): H2(P)+DKL,2(P∥Q)≤LQ<H2(P)+DKL,2(P∥Q)+1.\boxed{ H_2(P)+D_{\mathrm{KL},2}(P\|Q) \le L_Q < H_2(P)+D_{\mathrm{KL},2}(P\|Q)+1 }.

Интерпретация:

  • H2(P)H_2(P) — неизбежная средняя неопределённость источника;
  • DKL,2(P∥Q)D_{\mathrm{KL},2}(P\|Q) — цена несовпадения модели с источником (model mismatch);
  • остаток LQ−H2(P,Q)∈[0,1)L_Q-H_2(P,Q)\in[0,1) — цена целочисленных односимвольных длин.

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

Упражнение 4. Завершение арифметического кода

Для строки ACB из §6.4:

  1. проверьте финальный интервал [0.45,0.48)[0.45,0.48);

  2. покажите, почему пятибитный префикс 01111 недостаточен как диадический интервал;

  3. найдите шестибитный интервал, целиком содержащийся внутри финального;

  4. сравните фактические 6 бит с идеальной длиной −log⁡20.03-\log_2 0.03.

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

Распределение:

p(A)=0.5,p(B)=0.3,p(C)=0.2.p(A)=0.5, \qquad p(B)=0.3, \qquad p(C)=0.2.

Шаг 1. Финальный интервал

После AA:

[0,1)→[0,0.5).[0,1)\to[0,0.5).

Внутри этого интервала часть, соответствующая CC, занимает последние 20%20\%:

[0.4,0.5).[0.4,0.5).

Ширина равна 0.10.1. Внутри неё BB занимает долю от 0.50.5 до 0.80.8:

[0.4+0.1⋅0.5,0.4+0.1⋅0.8)=[0.45,0.48).[0.4+0.1\cdot0.5, 0.4+0.1\cdot0.8) =[0.45,0.48).

Ширина:

0.48−0.45=0.03=p(A)p(C)p(B).0.48-0.45=0.03 =p(A)p(C)p(B).

Шаг 2. Почему 01111 не завершает код

Число

0.011112=1532=0.468750.01111_2=\frac{15}{32}=0.46875

действительно лежит внутри [0.45,0.48)[0.45,0.48). Но конечный префикс задаёт не одну точку, а все бинарные продолжения этого префикса, то есть интервал

[1532,1632)=[0.46875,0.5).\left[\frac{15}{32},\frac{16}{32}\right) =[0.46875,0.5).

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

Более того, любой пятибитный интервал имеет ширину

2−5=0.03125>0.03,2^{-5}=0.03125>0.03,

так что ни один пятибитный dyadic interval целиком в [0.45,0.48)[0.45,0.48) не поместится.

Шаг 3. Шестибитный интервал

Берём

[2964,3064)=[0.453125,0.46875).\left[\frac{29}{64},\frac{30}{64}\right) =[0.453125,0.46875).

Он целиком лежит внутри [0.45,0.48)[0.45,0.48). Число 29 в шестибитной записи:

29=(011101)2,29=(011101)_2,

поэтому можно использовать префикс 011101.

Шаг 4. Сравнение с идеальной длиной

−log⁡20.03≈5.058894 бит.-\log_2 0.03 \approx5.058894\ \text{бит}.

Фактический код имеет 6 бит, поэтому его избыточность для этой короткой строки равна

6−5.058894≈0.941106 бит.6-5.058894 \approx0.941106\ \text{бит}.

Она меньше двухбитной общей границы идеализированного арифметического завершения.

Упражнение 5. Python: gzip, zstd и NLL языковой модели

Возьмите один фиксированный UTF-8-файл размером 10–100 KB.

  1. Сожмите его gzip и zstd.

  2. Посчитайте NLL небольшой авторегрессионной модели, например GPT-2, со скользящим окном (sliding window).

  3. Переведите NLL в теоретическое число бит и BPB.

  4. Проверьте, что каждый исходный токен вошёл в функцию потерь ровно один раз.

  5. Сравните теоретическую длину кода модели с реальными архивами, явно указав, что веса модели не включены.

Проверенный код приведён в решениях.

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

Ниже код сознательно не загружает текст из интернета. Эксперимент должен работать с зафиксированным локальным файлом corpus.txt; это делает результат воспроизводимее и не скрывает preprocessing.

Зависимости:

bash
pip install torch transformers zstandard
Python
from __future__ import annotations

import gzip
import hashlib
import math
from pathlib import Path

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

try:
    import zstandard as zstd
except ImportError as exc:
    raise SystemExit("Install zstandard: pip install zstandard") from exc


FILE_PATH = Path("corpus.txt")
MODEL_NAME = "openai-community/gpt2"
MODEL_REVISION = "607a30d783dfa663caf39e06633721c8d4cfcd7e"
ZSTD_LEVEL = 19
REQUESTED_STRIDE = 512


def read_utf8_file(path: Path) -> tuple[bytes, str]:
    raw = path.read_bytes()
    text = raw.decode("utf-8")
    if not raw:
        raise ValueError("The input file is empty.")
    return raw, text


def model_max_length(model: torch.nn.Module) -> int:
    for name in ("max_position_embeddings", "n_positions", "n_ctx"):
        value = getattr(model.config, name, None)
        if isinstance(value, int) and value > 1:
            return value
    raise ValueError("Could not determine the model context length.")


def causal_lm_nll(
    text: str,
    model_name: str,
    requested_stride: int = 512,
    revision: str = MODEL_REVISION,
) -> dict[str, float | int | str]:
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    tokenizer = AutoTokenizer.from_pretrained(model_name, revision=revision, use_fast=True)
    model = AutoModelForCausalLM.from_pretrained(model_name, revision=revision).to(device).eval()

    token_ids = tokenizer(
        text,
        add_special_tokens=False,
        return_tensors="pt",
    ).input_ids

    n_text_tokens = int(token_ids.size(1))
    if n_text_tokens == 0:
        raise ValueError("The tokenizer produced no tokens.")

    # GPT-2 has no separate BOS token. Using EOS as the shared start symbol
    # defines the probability assigned to the first text token.
    start_id = tokenizer.bos_token_id
    if start_id is None:
        start_id = tokenizer.eos_token_id
    if start_id is None:
        raise ValueError("The tokenizer has neither BOS nor EOS token.")

    start = torch.tensor([[start_id]], dtype=token_ids.dtype)
    ids = torch.cat([start, token_ids], dim=1)

    max_len = model_max_length(model)
    stride = min(requested_stride, max_len - 1)
    if stride < 1:
        raise ValueError("The context length must be at least 2.")

    nll_sum_nats = 0.0
    n_counted = 0

    # Positions 1..N in `ids` are the original text tokens. Each target
    # interval is disjoint, while its input window may overlap for context.
    target_start = 1
    while target_start < ids.size(1):
        target_end = min(target_start + stride, ids.size(1))
        begin = max(0, target_end - max_len)

        chunk = ids[:, begin:target_end].to(device)
        labels = chunk.clone()
        local_target_start = target_start - begin
        labels[:, :local_target_start] = -100

        with torch.inference_mode():
            output = model(input_ids=chunk, labels=labels)

        # Causal-LM loss compares logits[:, :-1] with labels[:, 1:].
        # Counting after this shift avoids the common off-by-one bug.
        n_loss_tokens = int((labels[:, 1:] != -100).sum().item())
        expected = target_end - target_start
        if n_loss_tokens != expected:
            raise RuntimeError(
                f"Window accounting error: {n_loss_tokens=} != {expected=}"
            )

        nll_sum_nats += float(output.loss.item()) * n_loss_tokens
        n_counted += n_loss_tokens
        target_start = target_end

    if n_counted != n_text_tokens:
        raise RuntimeError(
            f"Counted {n_counted} targets, expected {n_text_tokens}."
        )

    total_bits = nll_sum_nats / math.log(2.0)
    return {
        "device": str(device),
        "n_tokens": n_text_tokens,
        "nll_nats": nll_sum_nats,
        "ppl": math.exp(nll_sum_nats / n_counted),
        "bits_per_token": total_bits / n_counted,
        "total_bits": total_bits,
    }


def main() -> None:
    raw, text = read_utf8_file(FILE_PATH)

    gzip_bytes = gzip.compress(raw, compresslevel=9)
    zstd_bytes = zstd.ZstdCompressor(level=ZSTD_LEVEL).compress(raw)
    lm = causal_lm_nll(text, MODEL_NAME, REQUESTED_STRIDE)

    n_bytes = len(raw)
    n_chars = len(text)  # Python Unicode code points, not grapheme clusters.
    theoretical_bytes = float(lm["total_bits"]) / 8.0

    print(f"File: {FILE_PATH}")
    print(f"UTF-8 bytes: {n_bytes:,}; Python characters: {n_chars:,}")
    print(f"Model: {MODEL_NAME}; device: {lm['device']}")
    print(f"Model revision: {MODEL_REVISION}")
    print(f"Corpus SHA-256: {hashlib.sha256(raw).hexdigest()}")
    print(f"Counted text tokens: {lm['n_tokens']:,}")
    print()

    print("Real archives (headers included):")
    print(
        f"  gzip -9: {len(gzip_bytes):,} bytes, "
        f"{8 * len(gzip_bytes) / n_bytes:.4f} BPB"
    )
    print(
        f"  zstd -{ZSTD_LEVEL}: {len(zstd_bytes):,} bytes, "
        f"{8 * len(zstd_bytes) / n_bytes:.4f} BPB"
    )
    print()

    print("Causal-LM NLL-equivalent code (not an emitted archive):")
    print(f"  perplexity: {lm['ppl']:.4f}")
    print(f"  bits/token: {lm['bits_per_token']:.4f}")
    print(f"  theoretical bytes: {theoretical_bytes:,.1f}")
    print(f"  BPB: {float(lm['total_bits']) / n_bytes:.4f}")
    print(f"  BPC: {float(lm['total_bits']) / n_chars:.4f}")
    print()
    print("Model weights, tokenizer files, and entropy-coder overhead are excluded.")


if __name__ == "__main__":
    main()

Почему подсчёт сделан именно так

Causal LM внутри forward(..., labels=...) сдвигает объекты:

logits[:, :-1]сравниваются сlabels[:, 1:].\texttt{logits[:, :-1]} \quad\text{сравниваются с}\quad \texttt{labels[:, 1:]}.

Поэтому умножать output.loss просто на длину немаскированного хвоста опасно: первый label окна после сдвига может не участвовать в loss. Код считает число объектов непосредственно после логического сдвига:

Python
n_loss_tokens = int((labels[:, 1:] != -100).sum().item())

Каждый исходный токен принадлежит ровно одному непересекающемуся target-интервалу, хотя контекстные окна перекрываются. Финальная проверка

Python
assert n_counted == n_text_tokens

защищает от пропущенных и повторно посчитанных токенов.

Что именно сравнивается

  • gzip и zstd создают реальные self-contained архивные потоки; их размеры включают заголовки формата.
  • NLL модели даёт идеальную длину data code при уже известной модели, но код не создаёт настоящий arithmetic/range-coded файл.
  • Веса GPT-2, токенизатор, загрузчик модели и finite-precision entropy coder не включены.
  • Использование EOS как начального символа — часть protocol. Кодер и декодер должны договориться об этом заранее.
  • Результаты зависят от версии файла, модели, токенизатора, контекстной политики и возможной контаминации pretraining data. Поэтому в решении нет «эталонного» числа BPB.

Бонус. Байесовская смесь как универсальный код

Для двух моделей Бернулли с параметрами p1=0.2p_1=0.2 и p2=0.8p_2=0.8 и равномерными априорными весами постройте вероятность последовательности под их смесью. Численно проверьте

−log⁡2Q(x1:n)≤min⁡j[−log⁡2Pj(x1:n)]+1.-\log_2Q(x_{1:n}) \le \min_j[-\log_2P_j(x_{1:n})]+1.
Показать ответ и решение

Пусть x1:n∈{0,1}nx_{1:n}\in\{0,1\}^n, а две модели имеют параметры

p1=0.2,p2=0.8,p_1=0.2, \qquad p_2=0.8,

и веса w1=w2=1/2w_1=w_2=1/2. Для последовательности с kk единицами

Pj(x1:n)=pjk(1−pj)n−k,P_j(x_{1:n}) =p_j^k(1-p_j)^{n-k},

а смесь равна

Q(x1:n)=12P1(x1:n)+12P2(x1:n).Q(x_{1:n}) =\frac12P_1(x_{1:n})+\frac12P_2(x_{1:n}).

Так как для каждого jj

Q(x1:n)≥12Pj(x1:n),Q(x_{1:n})\ge\frac12P_j(x_{1:n}),

то

−log⁡2Q(x1:n)≤−log⁡2Pj(x1:n)+1.-\log_2Q(x_{1:n}) \le -\log_2P_j(x_{1:n})+1.

Минимизируя правую часть по jj, получаем требуемую границу.

Численная проверка в log-space:

Python
from __future__ import annotations

import math
import random


def log2sumexp(values: list[float]) -> float:
    m = max(values)
    return m + math.log2(sum(2.0 ** (v - m) for v in values))


def bernoulli_log2_prob(sequence: list[int], p: float) -> float:
    ones = sum(sequence)
    zeros = len(sequence) - ones
    return ones * math.log2(p) + zeros * math.log2(1.0 - p)


def check_sequence(sequence: list[int]) -> tuple[float, float]:
    parameters = [0.2, 0.8]
    log2_weights = [math.log2(0.5), math.log2(0.5)]
    model_log_probs = [
        bernoulli_log2_prob(sequence, p) for p in parameters
    ]

    mixture_log_prob = log2sumexp(
        [w + lp for w, lp in zip(log2_weights, model_log_probs)]
    )
    mixture_length = -mixture_log_prob
    best_model_length = min(-lp for lp in model_log_probs)

    if mixture_length > best_model_length + 1.0 + 1e-12:
        raise AssertionError("Mixture regret bound was violated.")

    return mixture_length, best_model_length


rng = random.Random(6)
for n in (1, 5, 20, 100, 1000):
    for _ in range(100):
        sequence = [rng.randrange(2) for _ in range(n)]
        mixture_length, best_length = check_sequence(sequence)
    print(
        f"n={n:4d}: last mixture={mixture_length:10.4f} bits, "
        f"best={best_length:10.4f}, regret={mixture_length-best_length:.4f}"
    )

Здесь единица в границе — это

log⁡21wj=log⁡22=1 бит.\log_2\frac1{w_j}=\log_22=1\ \text{бит}.

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