Модуль 6. Кодирование источника: от энтропии к реальным битам
Сначала попробуйте решить упражнение. Ответы, подробные решения и проектные ориентиры раскрываются отдельно и сохраняют своё состояние при переключении RU/EN.
Скачать файлы курса (.zip) 33,9 КБАрхив содержит девять Python-файлов решений, общих для RU/EN, с сохранением структуры каталогов.
Упражнение 1. Хаффман руками
Постройте код Хаффмана для
Вычислите , и . Проверьте неравенство Крафта.
Показать ответ и решение
Обозначим символы через
Один из допустимых порядков объединений:
-
;
-
;
-
;
-
.
После назначения битов ветвям можно получить код:
| Символ | Вероятность | Код | Длина |
|---|---|---|---|
| 0.40 | 0 | 1 | |
| 0.10 | 100 | 3 | |
| 0.15 | 101 | 3 | |
| 0.15 | 110 | 3 | |
| 0.20 | 111 | 3 |
Конкретные нули и единицы можно поменять местами; существенны длины кодовых слов.
Средняя длина:
Энтропия:
Избыточность:
Проверка Крафта:
Код использует весь бюджет Крафта. Поэтому в разложении
здесь , и весь зазор связан с тем, что диадическое распределение, заданное длинами, не совпадает с .
Упражнение 2. Когда достигается
Докажите, что односимвольный префиксный код достигает тогда и только тогда, когда распределение диадическое:
и соответствующие длины удовлетворяют равенству Крафта.
Постройте пример для
Показать ответ и решение
Для префиксного кода положим
Из доказательства односимвольной теоремы:
Оба слагаемых неотрицательны. Поэтому равенство возможно тогда и только тогда, когда одновременно
Первое равенство означает , второе — полное использование бюджета Крафта. Вместе они дают
Поскольку длины целые, все вероятности должны иметь вид
Это необходимость. Обратно, если распределение диадическое и
теорема Крафта гарантирует существование префиксного кода с длинами . Тогда
Для распределения
подходит код:
| Исход | Вероятность | Длина | Код |
|---|---|---|---|
| 1 | 1 | 0 | |
| 2 | 2 | 10 | |
| 3 | 3 | 110 | |
| 4 | 4 | 1110 | |
| 5 | 5 | 11110 | |
| 6 | 5 | 11111 |
Проверка:
а средняя длина равна
Энтропия имеет то же значение, потому что для каждого исхода.
Упражнение 3. Неверная модель как лишние биты
Пусть длины строятся по :
Покажите, что
Объясните, какая часть связана с несовпадением модели с источником, а какая — с целочисленным округлением.
Показать ответ и решение
Предполагаем, что
для каждого с . Иначе кросс-энтропия и средняя длина бесконечны.
По определению потолка:
Умножаем на и суммируем:
Используем разложение
Интерпретация:
- — неизбежная средняя неопределённость источника;
- — цена несовпадения модели с источником (
model mismatch); - остаток — цена целочисленных односимвольных длин.
Для арифметического или длинного блочного кода округление амортизируется на всю последовательность, тогда как KL-цена неверной модели остаётся пропорциональной числу символов.
Упражнение 4. Завершение арифметического кода
Для строки ACB из §6.4:
-
проверьте финальный интервал ;
-
покажите, почему пятибитный префикс
01111недостаточен как диадический интервал; -
найдите шестибитный интервал, целиком содержащийся внутри финального;
-
сравните фактические 6 бит с идеальной длиной .
Показать ответ и решение
Распределение:
Шаг 1. Финальный интервал
После :
Внутри этого интервала часть, соответствующая , занимает последние :
Ширина равна . Внутри неё занимает долю от до :
Ширина:
Шаг 2. Почему 01111 не завершает код
Число
действительно лежит внутри . Но конечный префикс задаёт не одну точку, а все бинарные продолжения этого префикса, то есть интервал
Его правая часть выходит за пределы финального интервала, поэтому декодер не может однозначно заключить, что была закодирована именно строка ACB.
Более того, любой пятибитный интервал имеет ширину
так что ни один пятибитный dyadic interval целиком в не поместится.
Шаг 3. Шестибитный интервал
Берём
Он целиком лежит внутри . Число 29 в шестибитной записи:
поэтому можно использовать префикс 011101.
Шаг 4. Сравнение с идеальной длиной
Фактический код имеет 6 бит, поэтому его избыточность для этой короткой строки равна
Она меньше двухбитной общей границы идеализированного арифметического завершения.
Упражнение 5. Python: gzip, zstd и NLL языковой модели
Возьмите один фиксированный UTF-8-файл размером 10–100 KB.
-
Сожмите его
gzipиzstd. -
Посчитайте NLL небольшой авторегрессионной модели, например GPT-2, со скользящим окном (
sliding window). -
Переведите NLL в теоретическое число бит и BPB.
-
Проверьте, что каждый исходный токен вошёл в функцию потерь ровно один раз.
-
Сравните теоретическую длину кода модели с реальными архивами, явно указав, что веса модели не включены.
Проверенный код приведён в решениях.
Показать ответ и решение
Ниже код сознательно не загружает текст из интернета. Эксперимент должен работать с зафиксированным локальным файлом corpus.txt; это делает результат воспроизводимее и не скрывает preprocessing.
Зависимости:
pip install torch transformers zstandardfrom __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=...) сдвигает объекты:
Поэтому умножать output.loss просто на длину немаскированного хвоста опасно: первый label окна после сдвига может не участвовать в loss. Код считает число объектов непосредственно после логического сдвига:
n_loss_tokens = int((labels[:, 1:] != -100).sum().item())Каждый исходный токен принадлежит ровно одному непересекающемуся target-интервалу, хотя контекстные окна перекрываются. Финальная проверка
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.
Бонус. Байесовская смесь как универсальный код
Для двух моделей Бернулли с параметрами и и равномерными априорными весами постройте вероятность последовательности под их смесью. Численно проверьте
Показать ответ и решение
Пусть , а две модели имеют параметры
и веса . Для последовательности с единицами
а смесь равна
Так как для каждого
то
Минимизируя правую часть по , получаем требуемую границу.
Численная проверка в log-space:
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}"
)Здесь единица в границе — это
Она платится один раз за всю последовательность, а не на каждом шаге.