VKВлад КуликовОткрытые лекции
Современные LLM16 / 20

Модуль 16. Мультимодальные LLM: зрение, аудио и видео

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

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

Архив содержит файлы на обоих языках и вспомогательные материалы с сохранением структуры каталогов.

Ноутбук 16. Мультимодальная LLM: от пикселей до входа декодера

Основной ноутбук модуля

Скачать .ipynb module_16_notebook_mm.ipynb · 76,1 КБ

Показать текст ноутбука

Ноутбук 16. Мультимодальная LLM: от пикселей до входа декодера

Модуль 16 — практический ноутбук

Начнём с массива пикселей [H,W,C]: выделим патчи, объединим соседние позиции и передадим признаки через проектор языковому декодеру. Так станет видно, где меняется длина последовательности, а где только ширина представления. Затем посчитаем контрастивную потерю и память, выберем кадры видео и обучим маленькую модель отвечать по изображению. Аудиопример в конце сопоставит временные кадры с непрерывными признаками и дискретными кодами.

Colab-контракт: Runtime → Run all выполняет обязательный маршрут на CPU в чистой сессии. Ноутбук не читает файлы репозитория, не требует ручной загрузки, Google Drive, API-ключей или платных сервисов. Замороженный численный контракт M16 встроен и проверяется по SHA-256.

Маршрут:

  1. точная арифметика патчей для прямоугольных изображений;

  2. реальный patchify и обратная сборка без потери данных;

  3. объединение 2×2 с явным изменением формы;

  4. двухслойный проектор vision→LLM и точное число параметров;

  5. замена метки <image> непрерывными визуальными эмбеддингами;

  6. численно устойчивый симметричный InfoNCE;

  7. KV и бюджет видео в KiB/MiB/GiB;

  8. негативные тесты контрактов;

  9. обучение малой VLM и проверка её ответов;

  10. аудиокадры, усреднение, RVQ и подсчёт позиций (§11).

Python
import importlib.util
import subprocess
import sys

for package in ("numpy", "torch", "matplotlib"):
    if importlib.util.find_spec(package) is None:
        subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", package])

import hashlib
import json
import math
from dataclasses import dataclass

import numpy as np

np.set_printoptions(precision=5, suppress=True)
SEED = 16
rng = np.random.default_rng(SEED)
print('Окружение готово.')

def _positive_finite(name: str, value: float) -> float:
    """Validate and return one strictly positive finite floating-point value."""

    value = float(value)
    if not math.isfinite(value) or value <= 0.0:
        raise ValueError(f"{name} must be finite and positive")
    return value

0. Встроенный численный контракт M16

Эталонные числа принадлежат модулю и не вычисляются из текста лекции. JSON встроен в ноутбук и проверяется по SHA-256: будущая редактура лекции не меняет вычислительный маршрут и не ломает ответы.

Python
REFERENCE_JSON = '{"infonce":{"batch_size":4,"negative_similarity":0.2,"positive_similarity":0.8,"tau_0_1":{"image_to_text":0.00740874388428169,"symmetric":0.00740874388428169,"text_to_image":0.00740874388428169},"tau_0_1_probability":0.9926186332070156,"tau_0_5":{"image_to_text":0.6437377084206329,"symmetric":0.6437377084206329,"text_to_image":0.6437377084206329},"uniform_floor":1.3862943611198906},"kv":{"device_budget_gib":80.0,"per_image":{"llava336":{"available_gib":65.0,"capacity_upper_bound":924,"kv_bytes":75497472,"kv_mib":72.0,"visual_tokens":576},"next672":{"available_gib":65.0,"capacity_upper_bound":184,"kv_bytes":377487360,"kv_mib":360.0,"visual_tokens":2880},"qwen1008_merged":{"available_gib":65.0,"capacity_upper_bound":410,"kv_bytes":169869312,"kv_mib":162.0,"visual_tokens":1296}},"spec":{"bytes_per_element":2,"bytes_per_token":131072,"head_dim":128,"kib_per_token":128.0,"n_kv_heads":8,"n_layers":32},"weights_gib":15.0},"patches":{"llava336":576,"next672":2880,"qwen1008":5184,"qwen1008_merged":1296,"rectangular_56x28":8,"rectangular_56x28_merged":2},"projector":{"hidden_dim":4096,"llm_dim":4096,"parameter_count":20979712,"vision_dim":1024},"schema_version":1,"training_floor":{"flops_multiplier":6.0,"mfu":0.4,"n_parameters":7000000000.0,"peak_flops_per_second":990000000000000.0,"stage1_hours":9.863636363636365,"stage1_tokens":334800000,"stage2_hours":19.59175084175084,"stage2_tokens":665000000,"total_hours":29.455387205387204},"video":{"source_frames":7200,"with_merger_2x2":{"full_prompt_kv_gib":31.25,"full_prompt_tokens":256000,"max_frames":1750,"sample_interval_seconds":4.114285714285714,"selected_frames":1750,"total_frames":7200,"used_visual_tokens":252000,"visual_budget_tokens":252000,"visual_kv_gib":30.76171875},"without_merger":{"full_prompt_kv_gib":31.21484375,"full_prompt_tokens":255712,"max_frames":437,"sample_interval_seconds":16.475972540045767,"selected_frames":437,"total_frames":7200,"used_visual_tokens":251712,"visual_budget_tokens":252000,"visual_kv_gib":30.7265625}}}'
EXPECTED_SHA256 = 'b22068921a7c496a72b525b7015d5c7450e3b0da8fb0b2d482de6e97a899449c'
actual_sha256 = hashlib.sha256(REFERENCE_JSON.encode("utf-8")).hexdigest()
assert actual_sha256 == EXPECTED_SHA256
REFERENCE = json.loads(REFERENCE_JSON)
assert REFERENCE["schema_version"] == 1
print('Эталонный payload M16 распакован и проверен', actual_sha256[:12])

1. Геометрия патчей: сначала формы, затем числа

Формула (H/p)·(W/p) корректна только при точной делимости. Молчаливое // для неподходящего разрешения незаметно теряет край изображения, поэтому учебная функция проверяет обе оси. merge_factor=m объединяет m×m соседних патчей и требует, чтобы сама сетка патчей делилась по обеим осям.

Python
def _positive_int(name: str, value: int) -> int:
    """Validate and return one strictly positive integer."""

    if not isinstance(value, (int, np.integer)) or isinstance(value, bool) or int(value) <= 0:
        raise ValueError(f"{name} must be a positive integer")
    return int(value)



@dataclass(frozen=True)
class PatchLayout:
    """Spatial patch layout for one image before or after token merging."""

    image_height: int
    image_width: int
    patch_size: int
    merge_factor: int
    grid_height: int
    grid_width: int
    token_count: int



def patch_layout(
    image_height: int,
    image_width: int,
    *,
    patch_size: int = 14,
    merge_factor: int = 1,
) -> PatchLayout:
    """Return the exact patch grid and token count for a rectangular image.

    ``merge_factor=m`` groups each spatial ``m × m`` block into one visual token.
    Both the image and the resulting patch grid must be exactly divisible; silent
    truncation would under-count tokens and is therefore rejected.
    """

    image_height = _positive_int("image_height", image_height)
    image_width = _positive_int("image_width", image_width)
    patch_size = _positive_int("patch_size", patch_size)
    merge_factor = _positive_int("merge_factor", merge_factor)
    if image_height % patch_size or image_width % patch_size:
        raise ValueError("image dimensions must be divisible by patch_size")
    grid_height = image_height // patch_size
    grid_width = image_width // patch_size
    if grid_height % merge_factor or grid_width % merge_factor:
        raise ValueError("the patch grid must be divisible by merge_factor on both axes")
    merged_h = grid_height // merge_factor
    merged_w = grid_width // merge_factor
    return PatchLayout(
        image_height=image_height,
        image_width=image_width,
        patch_size=patch_size,
        merge_factor=merge_factor,
        grid_height=grid_height,
        grid_width=grid_width,
        token_count=merged_h * merged_w,
    )



P = REFERENCE["patches"]
assert patch_layout(336, 336).token_count == P["llava336"] == 576
assert 5 * patch_layout(336, 336).token_count == P["next672"] == 2880
assert patch_layout(1008, 1008).token_count == P["qwen1008"] == 5184
assert patch_layout(1008, 1008, merge_factor=2).token_count == P["qwen1008_merged"] == 1296
assert patch_layout(56, 28).token_count == P["rectangular_56x28"] == 8
assert patch_layout(56, 28, merge_factor=2).token_count == P["rectangular_56x28_merged"] == 2
print('Геометрия патчей проверена для квадратных и прямоугольных изображений')

2. Реальный patchify и пространственное объединение 2×2

Теперь работаем не со счётчиком, а с массивом [H,W,C]. patchify_rgb возвращает [N, p²C] в построчном порядке. Обратная сборка проверяет взаимную согласованность функций. Независимая сетка с известными координатами дополнительно проверяет построчный порядок патчей. Объединение 2×2 уменьшает число токенов в четыре раза и увеличивает ширину каждого токена в четыре раза; обучаемая проекция выполняется следующим шагом.

Python
def patchify_rgb(image: np.ndarray, patch_size: int) -> tuple[np.ndarray, tuple[int, int]]:
    """Convert one ``[H, W, C]`` image into flattened row-major patch vectors.

    Returns ``patches`` with shape ``[N_patches, patch_size² × C]`` and the unmerged
    spatial grid ``(grid_height, grid_width)``.  The function accepts any numeric dtype
    but rejects non-finite floating-point values.
    """

    array = np.asarray(image)
    if array.ndim != 3:
        raise ValueError("image must have shape [H, W, C]")
    height, width, channels = array.shape
    if channels <= 0:
        raise ValueError("image must contain at least one channel")
    layout = patch_layout(height, width, patch_size=patch_size)
    if not np.issubdtype(array.dtype, np.number):
        raise TypeError("image dtype must be numeric")
    if np.issubdtype(array.dtype, np.floating) and not np.all(np.isfinite(array)):
        raise ValueError("image contains NaN or infinity")
    patches = (
        array.reshape(layout.grid_height, patch_size, layout.grid_width, patch_size, channels)
        .transpose(0, 2, 1, 3, 4)
        .reshape(layout.token_count, patch_size * patch_size * channels)
    )
    return patches, (layout.grid_height, layout.grid_width)



def unpatchify_rgb(patches: np.ndarray, grid_shape: tuple[int, int], patch_size: int, channels: int) -> np.ndarray:
    """Invert patchify for a complete unmerged grid."""

    patches = np.asarray(patches)
    if len(grid_shape) != 2:
        raise ValueError("grid_shape must contain two dimensions")
    grid_height, grid_width = (_positive_int("grid dimension", v) for v in grid_shape)
    patch_size = _positive_int("patch_size", patch_size)
    channels = _positive_int("channels", channels)
    expected = (grid_height * grid_width, patch_size * patch_size * channels)
    if patches.shape != expected:
        raise ValueError(f"patches must have shape {expected}")
    return (
        patches.reshape(grid_height, grid_width, patch_size, patch_size, channels)
        .transpose(0, 2, 1, 3, 4)
        .reshape(grid_height * patch_size, grid_width * patch_size, channels)
    )


def merge_spatial_tokens(
    tokens: np.ndarray,
    grid_shape: tuple[int, int],
    *,
    merge_factor: int = 2,
) -> tuple[np.ndarray, tuple[int, int]]:
    """Group each spatial ``m × m`` block and concatenate its feature vectors.

    ``tokens`` may be ``[N, D]`` or batched ``[B, N, D]``.  The output keeps batch
    order and has shape ``[N/m², D·m²]`` or ``[B, N/m², D·m²]``.  This is the
    transparent pixel-unshuffle part of a merger; a learned projection can follow it.
    """

    array = np.asarray(tokens)
    if array.ndim not in {2, 3}:
        raise ValueError("tokens must have shape [N, D] or [B, N, D]")
    if len(grid_shape) != 2:
        raise ValueError("grid_shape must contain two integers")
    grid_height, grid_width = grid_shape
    grid_height = _positive_int("grid_height", grid_height)
    grid_width = _positive_int("grid_width", grid_width)
    merge_factor = _positive_int("merge_factor", merge_factor)
    n_tokens = array.shape[-2]
    feature_dim = array.shape[-1]
    if feature_dim == 0 or not np.issubdtype(array.dtype, np.number):
        raise ValueError("tokens must have nonempty numeric features")
    if n_tokens != grid_height * grid_width:
        raise ValueError("token count does not match grid_shape")
    if grid_height % merge_factor or grid_width % merge_factor:
        raise ValueError("grid_shape must be divisible by merge_factor")
    if np.issubdtype(array.dtype, np.floating) and not np.all(np.isfinite(array)):
        raise ValueError("tokens contain NaN or infinity")

    batched = array.ndim == 3
    work = array if batched else array[None, ...]
    batch = work.shape[0]
    merged_h = grid_height // merge_factor
    merged_w = grid_width // merge_factor
    merged = (
        work.reshape(batch, merged_h, merge_factor, merged_w, merge_factor, feature_dim)
        .transpose(0, 1, 3, 2, 4, 5)
        .reshape(batch, merged_h * merged_w, feature_dim * merge_factor * merge_factor)
    )
    return (merged if batched else merged[0]), (merged_h, merged_w)



image = np.arange(56 * 28 * 3, dtype=np.float32).reshape(56, 28, 3)
patches, grid = patchify_rgb(image, patch_size=14)
restored = unpatchify_rgb(patches, grid, patch_size=14, channels=3)
merged, merged_grid = merge_spatial_tokens(patches, grid, merge_factor=2)
assert patches.shape == (8, 14 * 14 * 3)
assert np.array_equal(restored, image)
# A round trip alone cannot establish the specified order: two inverse mistakes
# can cancel. Compare against an independently written coordinate fixture.
fixture = np.arange(16).reshape(4, 4, 1)
fixture_patches, _ = patchify_rgb(fixture, patch_size=2)
assert fixture_patches.tolist() == [[0,1,4,5], [2,3,6,7], [8,9,12,13], [10,11,14,15]]
fixture_merged, _ = merge_spatial_tokens(fixture_patches, (2,2), merge_factor=2)
assert fixture_merged.tolist() == [[0,1,4,5,2,3,6,7,8,9,12,13,10,11,14,15]]
assert merged.shape == (2, 4 * 14 * 14 * 3) and merged_grid == (2, 1)
print('Обратная сборка после `patchify` и объединение 2×2 проверены', "|", patches.shape, "->", merged.shape)

3. Проектор vision→LLM: формы и точное число параметров

Двухслойный MLP принимает [B,N_visual,D_vision] и возвращает [B,N_visual,D_llm]. Полноразмерный 1024→4096→4096 проектор только считаем, чтобы не выделять десятки MiB ради одной формы; прямой проход выполняем на малых детерминированных весах. Это сохраняет вычислительный контракт без маскировки механики фреймворком.

Python
def gelu_exact(x: np.ndarray) -> np.ndarray:
    """Evaluate exact GELU in float64 without requiring SciPy."""

    array = np.asarray(x, dtype=np.float64)
    erf = np.vectorize(math.erf, otypes=[np.float64])
    return 0.5 * array * (1.0 + erf(array / math.sqrt(2.0)))



def count_projector_parameters(
    vision_dim: int,
    llm_dim: int,
    *,
    hidden_dim: int | None = None,
    bias: bool = True,
) -> int:
    """Count parameters of a two-layer MLP vision projector exactly."""

    vision_dim = _positive_int("vision_dim", vision_dim)
    llm_dim = _positive_int("llm_dim", llm_dim)
    hidden_dim = llm_dim if hidden_dim is None else _positive_int("hidden_dim", hidden_dim)
    total = vision_dim * hidden_dim + hidden_dim * llm_dim
    if bias:
        total += hidden_dim + llm_dim
    return total



@dataclass(frozen=True)
class ProjectorWeights:
    """Weights of a two-layer MLP mapping vision features into the LLM width."""

    w1: np.ndarray
    b1: np.ndarray
    w2: np.ndarray
    b2: np.ndarray

    def validate(self) -> tuple[int, int, int]:
        """Validate matrix compatibility and return ``(vision, hidden, llm)`` widths."""

        w1 = np.asarray(self.w1)
        b1 = np.asarray(self.b1)
        w2 = np.asarray(self.w2)
        b2 = np.asarray(self.b2)
        if w1.ndim != 2 or w2.ndim != 2 or b1.ndim != 1 or b2.ndim != 1:
            raise ValueError("projector weights must be rank-2 matrices and rank-1 biases")
        vision_dim, hidden_dim = w1.shape
        if b1.shape != (hidden_dim,) or w2.shape[0] != hidden_dim:
            raise ValueError("w1/b1/w2 hidden dimensions are incompatible")
        llm_dim = w2.shape[1]
        if b2.shape != (llm_dim,):
            raise ValueError("w2 and b2 output dimensions are incompatible")
        for name, value in {"w1": w1, "b1": b1, "w2": w2, "b2": b2}.items():
            if not np.issubdtype(value.dtype, np.number) or not np.all(np.isfinite(value)):
                raise ValueError(f"{name} must be finite and numeric")
        return vision_dim, hidden_dim, llm_dim



def init_toy_projector(
    vision_dim: int,
    hidden_dim: int,
    llm_dim: int,
    *,
    seed: int = 14,
) -> ProjectorWeights:
    """Create deterministic small weights for a CPU-safe teaching forward pass."""

    vision_dim = _positive_int("vision_dim", vision_dim)
    hidden_dim = _positive_int("hidden_dim", hidden_dim)
    llm_dim = _positive_int("llm_dim", llm_dim)
    rng = np.random.default_rng(seed)
    w1 = rng.normal(0.0, 1.0 / math.sqrt(vision_dim), size=(vision_dim, hidden_dim))
    w2 = rng.normal(0.0, 1.0 / math.sqrt(hidden_dim), size=(hidden_dim, llm_dim))
    return ProjectorWeights(
        w1=w1.astype(np.float32),
        b1=np.zeros(hidden_dim, dtype=np.float32),
        w2=w2.astype(np.float32),
        b2=np.zeros(llm_dim, dtype=np.float32),
    )



def project_vision(features: np.ndarray, weights: ProjectorWeights) -> np.ndarray:
    """Project ``[..., N_visual, D_vision]`` features into ``D_llm`` with GELU."""

    array = np.asarray(features)
    if array.ndim < 2:
        raise ValueError("features must have shape [..., N_visual, D_vision]")
    vision_dim, _, _ = weights.validate()
    if array.shape[-1] != vision_dim:
        raise ValueError("features last dimension does not match projector vision_dim")
    if not np.issubdtype(array.dtype, np.number) or not np.all(np.isfinite(array)):
        raise ValueError("features must be finite and numeric")
    work = array.astype(np.float32, copy=False)
    hidden = gelu_exact(work @ weights.w1 + weights.b1).astype(np.float32)
    output = hidden @ weights.w2 + weights.b2
    if not np.all(np.isfinite(output)):
        raise FloatingPointError("projector produced NaN or infinity")
    return output.astype(np.float32, copy=False)



full = REFERENCE["projector"]
assert count_projector_parameters(full["vision_dim"], full["llm_dim"], hidden_dim=full["hidden_dim"]) == full["parameter_count"] == 20_979_712
vision_features = merged.reshape(2, 8, -1).mean(axis=-1, dtype=np.float64).astype(np.float32)[None, ...]
weights = init_toy_projector(vision_dim=8, hidden_dim=12, llm_dim=16)
visual_embeddings = project_vision(vision_features, weights)
assert visual_embeddings.shape == (1, 2, 16)
print('Проектор сохранил оси пакета и токенов и изменил только ширину признаков', "|", vision_features.shape, "->", visual_embeddings.shape, "| full params:", full["parameter_count"])

4. Сборка входа декодера: метка изображения заменяется визуальным блоком

Decoder-only VLM получает один поток эмбеддингов. В примере ровно один идентификатор токена <image> заменяется последовательностью <image_start>, visual_1…visual_N, <image_end>. Непрерывные визуальные эмбеддинги не являются словарными токенами, поэтому соответствующие labels равны -100; текстовые позиции сохраняют идентификаторы токенов. Причинная маска строится уже для расширенной последовательности.

Python
@dataclass(frozen=True)
class MultimodalSequence:
    """Decoder input after replacing one image placeholder by continuous features."""

    embeddings: np.ndarray
    token_labels: np.ndarray
    modality_ids: np.ndarray
    position_ids: np.ndarray
    attention_mask: np.ndarray



def assemble_multimodal_sequence(
    token_ids: np.ndarray,
    token_embeddings: np.ndarray,
    *,
    image_placeholder_id: int,
    image_embeddings: np.ndarray,
    image_start_embedding: np.ndarray,
    image_end_embedding: np.ndarray,
    ignore_index: int = -100,
) -> MultimodalSequence:
    """Replace exactly one placeholder token by start/vision/end embeddings.

    Text positions retain their token IDs as labels.  Continuous visual tokens and
    image-boundary embeddings receive ``ignore_index``.  The returned causal mask is
    ``[sequence, sequence]`` and positions are contiguous across modalities.
    """

    token_ids = np.asarray(token_ids)
    token_embeddings = np.asarray(token_embeddings)
    image_embeddings = np.asarray(image_embeddings)
    image_start_embedding = np.asarray(image_start_embedding)
    image_end_embedding = np.asarray(image_end_embedding)
    if token_ids.ndim != 1 or not np.issubdtype(token_ids.dtype, np.integer):
        raise ValueError("token_ids must be a rank-1 integer array")
    if token_embeddings.ndim != 2 or token_embeddings.shape[0] != token_ids.size:
        raise ValueError("token_embeddings must have shape [len(token_ids), D]")
    if image_embeddings.ndim != 2 or image_embeddings.shape[0] == 0:
        raise ValueError("image_embeddings must have shape [N_visual>0, D]")
    width = token_embeddings.shape[1]
    if image_embeddings.shape[1] != width:
        raise ValueError("text and image embeddings must share the LLM width")
    if image_start_embedding.shape != (width,) or image_end_embedding.shape != (width,):
        raise ValueError("image boundary embeddings must have shape [D]")
    for name, value in {
        "token_embeddings": token_embeddings,
        "image_embeddings": image_embeddings,
        "image_start_embedding": image_start_embedding,
        "image_end_embedding": image_end_embedding,
    }.items():
        if not np.all(np.isfinite(value)):
            raise ValueError(f"{name} contains NaN or infinity")
    if (isinstance(image_placeholder_id, bool)
            or not isinstance(image_placeholder_id, (int, np.integer))):
        raise ValueError("image_placeholder_id must be an integer")
    if np.any(token_ids < 0) or width == 0:
        raise ValueError("token IDs must be nonnegative and embedding width positive")
    placeholder_positions = np.flatnonzero(token_ids == image_placeholder_id)
    if placeholder_positions.size != 1:
        raise ValueError("token_ids must contain exactly one image placeholder")
    placeholder = int(placeholder_positions[0])

    before_embeddings = token_embeddings[:placeholder]
    after_embeddings = token_embeddings[placeholder + 1 :]
    visual_block = np.concatenate(
        [image_start_embedding[None, :], image_embeddings, image_end_embedding[None, :]],
        axis=0,
    )
    embeddings = np.concatenate([before_embeddings, visual_block, after_embeddings], axis=0)

    labels = np.concatenate(
        [
            token_ids[:placeholder],
            np.full(visual_block.shape[0], ignore_index, dtype=np.int64),
            token_ids[placeholder + 1 :],
        ]
    ).astype(np.int64)
    modality_ids = np.concatenate(
        [
            np.zeros(before_embeddings.shape[0], dtype=np.int8),
            np.full(visual_block.shape[0], 1, dtype=np.int8),
            np.zeros(after_embeddings.shape[0], dtype=np.int8),
        ]
    )
    sequence = embeddings.shape[0]
    position_ids = np.arange(sequence, dtype=np.int64)
    attention_mask = np.tril(np.ones((sequence, sequence), dtype=bool))
    return MultimodalSequence(
        embeddings=embeddings.astype(np.float32, copy=False),
        token_labels=labels,
        modality_ids=modality_ids,
        position_ids=position_ids,
        attention_mask=attention_mask,
    )



IMAGE_ID = 999
token_ids = np.array([101, 102, IMAGE_ID, 103, 104], dtype=np.int64)
text_embeddings = rng.normal(size=(token_ids.size, 16)).astype(np.float32)
sequence = assemble_multimodal_sequence(
    token_ids,
    text_embeddings,
    image_placeholder_id=IMAGE_ID,
    image_embeddings=visual_embeddings[0],
    image_start_embedding=np.zeros(16, dtype=np.float32),
    image_end_embedding=np.ones(16, dtype=np.float32),
)
assert sequence.embeddings.shape == (8, 16)
assert sequence.token_labels.tolist() == [101, 102, -100, -100, -100, -100, 103, 104]
assert sequence.modality_ids.tolist() == [0, 0, 1, 1, 1, 1, 0, 0]
assert np.array_equal(sequence.position_ids, np.arange(8))
assert np.array_equal(sequence.attention_mask, np.tril(np.ones((8, 8), dtype=bool)))
assert not sequence.attention_mask[2, 3] and sequence.attention_mask[7, 0]
print('Placeholder заменён визуальным блоком; `labels`, `modality_ids` и причинная маска согласованы', "| sequence:", sequence.embeddings.shape)

5. Симметричный InfoNCE без переполнения

Для контрастивного обучения берём квадратную матрицу сходств image↔text, где диагональ задаёт правильные пары. Функция потерь считается в обе стороны через log-sum-exp, а не через прямой exp, поэтому экспоненты не переполняются; непредставимая итоговая потеря вызывает явную ошибку. Отдельно проверяем L2-нормировку строк эмбеддингов и невозможные входы.

Python
def _logsumexp(values: np.ndarray, axis: int) -> np.ndarray:
    """Stable log-sum-exp for finite arrays."""

    maximum = np.max(values, axis=axis, keepdims=True)
    return np.squeeze(maximum + np.log(np.sum(np.exp(values - maximum), axis=axis, keepdims=True)), axis=axis)



def l2_normalize_rows(embeddings: np.ndarray, *, eps: float = 1e-12) -> np.ndarray:
    """L2-normalize a finite ``[batch, width]`` matrix and reject zero rows."""

    array = np.asarray(embeddings, dtype=np.float64)
    if array.ndim != 2 or array.shape[0] < 2 or array.shape[1] == 0:
        raise ValueError("embeddings must have shape [batch>=2, width>0]")
    if not np.all(np.isfinite(array)):
        raise ValueError("embeddings contain NaN or infinity")
    eps = _positive_finite("eps", eps)
    scale = np.max(np.abs(array), axis=1, keepdims=True)
    if np.any(scale == 0):
        raise ValueError("embedding rows must have non-zero norm")
    scaled = array / scale
    norms = np.linalg.norm(scaled, axis=1, keepdims=True)
    if np.any(scale <= eps / norms):
        raise ValueError("embedding row norm must exceed eps")
    return scaled / norms



def symmetric_infonce(similarity: np.ndarray, temperature: float) -> dict[str, float]:
    """Return stable image→text, text→image, and symmetric InfoNCE losses.

    ``similarity`` must be a finite square matrix whose diagonal contains the aligned
    image-text pairs.  Log-sum-exp arithmetic avoids overflow at small temperatures.
    """

    similarity = np.asarray(similarity, dtype=np.float64)
    if similarity.ndim != 2 or similarity.shape[0] != similarity.shape[1] or similarity.shape[0] < 2:
        raise ValueError("similarity must be a square [batch>=2, batch] matrix")
    if not np.all(np.isfinite(similarity)):
        raise ValueError("similarity contains NaN or infinity")
    temperature = _positive_finite("temperature", temperature)
    # Center BEFORE dividing; equal huge finite scores need not overflow.
    def directional(values):
        with np.errstate(over='ignore', invalid='ignore'):
            logits = (values - np.max(values, axis=1, keepdims=True)) / temperature
            loss = _logsumexp(logits, axis=1) - np.diag(logits)
        if not np.all(np.isfinite(loss)):
            raise FloatingPointError("InfoNCE exceeds the float64 range")
        return float(np.mean(loss))
    image_to_text = directional(similarity)
    text_to_image = directional(similarity.T)
    return {
        "image_to_text": image_to_text,
        "text_to_image": text_to_image,
        "symmetric": 0.5 * (image_to_text + text_to_image),
    }



I = REFERENCE["infonce"]
similarity = np.full((I["batch_size"], I["batch_size"]), I["negative_similarity"], dtype=np.float64)
np.fill_diagonal(similarity, I["positive_similarity"])
loss01 = symmetric_infonce(similarity, 0.1)
loss05 = symmetric_infonce(similarity, 0.5)
for key in ("image_to_text", "text_to_image", "symmetric"):
    assert math.isclose(loss01[key], I["tau_0_1"][key], rel_tol=0, abs_tol=1e-12)
    assert math.isclose(loss05[key], I["tau_0_5"][key], rel_tol=0, abs_tol=1e-12)
assert loss01["symmetric"] < loss05["symmetric"] < I["uniform_floor"]
normalized = l2_normalize_rows(rng.normal(size=(4, 8)))
assert np.allclose(np.linalg.norm(normalized, axis=1), 1.0)
assert np.max(np.abs(normalized @ normalized.T)) <= 1.0 + 1e-12
print('Направления image→text и text→image и симметричный InfoNCE воспроизведены устойчиво', "| tau=.1:", loss01["symmetric"], "| tau=.5:", loss05["symmetric"])

6. KV-цена визуальных позиций в строгих двоичных единицах

Сначала выводим bytes/token из геометрии K+V: 2 × layers × KV_heads × head_dim × bytes_per_element. Для учебной конфигурации это 128 KiB/token. Поскольку делим на степени двойки, результаты называются MiB/GiB. Число изображений на карте — верхняя граница только по KV: оно не учитывает активации, фрагментацию, временные буферы и накладные расходы планировщика.

Python
@dataclass(frozen=True)
class KVCacheSpec:
    """K+V geometry for one decoder token across all layers."""

    n_layers: int
    n_kv_heads: int
    head_dim: int
    bytes_per_element: int

    def bytes_per_token(self) -> int:
        """Return exact K+V bytes per token across all layers."""

        n_layers = _positive_int("n_layers", self.n_layers)
        n_kv_heads = _positive_int("n_kv_heads", self.n_kv_heads)
        head_dim = _positive_int("head_dim", self.head_dim)
        bytes_per_element = _positive_int("bytes_per_element", self.bytes_per_element)
        return 2 * n_layers * n_kv_heads * head_dim * bytes_per_element



def image_kv_metrics(
    visual_tokens: int,
    spec: KVCacheSpec,
    *,
    device_budget_gib: float,
    weights_gib: float,
    runtime_reserve_gib: float = 0.0,
) -> dict[str, float | int]:
    """Compute one image's KV memory and a KV-only capacity upper bound.

    The capacity ignores activations, allocator fragmentation, temporary buffers, and
    scheduling overhead.  It is therefore explicitly an upper bound, not a deployment
    promise.
    """

    visual_tokens = _positive_int("visual_tokens", visual_tokens)
    device_budget_gib = _positive_finite("device_budget_gib", device_budget_gib)
    weights_gib = _positive_finite("weights_gib", weights_gib)
    runtime_reserve_gib = float(runtime_reserve_gib)
    if not math.isfinite(runtime_reserve_gib) or runtime_reserve_gib < 0.0:
        raise ValueError("runtime_reserve_gib must be finite and non-negative")
    available_gib = device_budget_gib - weights_gib - runtime_reserve_gib
    if available_gib <= 0.0:
        raise ValueError("weights plus reserve leave no positive KV budget")
    kv_bytes = visual_tokens * spec.bytes_per_token()
    kv_mib = kv_bytes / 2**20
    capacity_upper_bound = int((available_gib * 2**30) // kv_bytes)
    return {
        "visual_tokens": visual_tokens,
        "kv_bytes": kv_bytes,
        "kv_mib": kv_mib,
        "available_gib": available_gib,
        "capacity_upper_bound": capacity_upper_bound,
    }



K = REFERENCE["kv"]
spec = KVCacheSpec(**{key: K["spec"][key] for key in ("n_layers", "n_kv_heads", "head_dim", "bytes_per_element")})
assert spec.bytes_per_token() == K["spec"]["bytes_per_token"] == 128 * 2**10
rows = []
for name in ("llava336", "next672", "qwen1008_merged"):
    expected = K["per_image"][name]
    actual = image_kv_metrics(expected["visual_tokens"], spec, device_budget_gib=K["device_budget_gib"], weights_gib=K["weights_gib"])
    assert math.isclose(actual["kv_mib"], expected["kv_mib"], abs_tol=1e-12)
    assert actual["capacity_upper_bound"] == expected["capacity_upper_bound"]
    reserved = image_kv_metrics(expected["visual_tokens"], spec, device_budget_gib=80.0, weights_gib=15.0, runtime_reserve_gib=8.0)
    assert reserved["capacity_upper_bound"] <= actual["capacity_upper_bound"]
    rows.append((name, expected["visual_tokens"], actual["kv_mib"], actual["capacity_upper_bound"], reserved["capacity_upper_bound"]))
print('KV bytes/token, MiB/image и capacity upper bounds проверены')
for row in rows:
    print(f"{row[0]:>18}: {row[1]:4d} tok | {row[2]:6.1f} MiB | KV-only cap {row[3]:3d} | with 8 GiB reserve {row[4]:3d}")

7. Проектор и эквивалент плотного обучения

Число параметров вычисляется точно. Формула 6·N·D ниже задаёт сценарий, эквивалентный плотному обучению (dense-equivalent), при указанных пиковых FLOP/s и MFU. Это не измеренное календарное время: замороженные компоненты и накладные расходы реального конвейера требуют отдельного учёта.

Python
def estimate_dense_training_hours(
    n_parameters: float,
    training_tokens: float,
    *,
    peak_flops_per_second: float,
    mfu: float,
    flops_multiplier: float = 6.0,
) -> float:
    """Return a transparent dense-equivalent training scenario in hours."""

    n_parameters = _positive_finite("n_parameters", n_parameters)
    training_tokens = _positive_finite("training_tokens", training_tokens)
    peak_flops_per_second = _positive_finite("peak_flops_per_second", peak_flops_per_second)
    mfu = float(mfu)
    if not math.isfinite(mfu) or not 0.0 < mfu <= 1.0:
        raise ValueError("mfu must lie in (0, 1]")
    flops_multiplier = _positive_finite("flops_multiplier", flops_multiplier)
    return flops_multiplier * n_parameters * training_tokens / (peak_flops_per_second * mfu) / 3600.0



C = REFERENCE["training_floor"]
kwargs = dict(
    n_parameters=C["n_parameters"],
    peak_flops_per_second=C["peak_flops_per_second"],
    mfu=C["mfu"],
    flops_multiplier=C["flops_multiplier"],
)
s1 = estimate_dense_training_hours(training_tokens=C["stage1_tokens"], **kwargs)
s2 = estimate_dense_training_hours(training_tokens=C["stage2_tokens"], **kwargs)
assert math.isclose(s1, C["stage1_hours"], abs_tol=1e-12)
assert math.isclose(s2, C["stage2_hours"], abs_tol=1e-12)
assert math.isclose(s1 + s2, C["total_hours"], abs_tol=1e-12)
print('Точное число параметров проектора и сценарий эквивалента плотного обучения воспроизведены', "| projector params:", REFERENCE["projector"]["parameter_count"], "| scenario hours:", round(s1 + s2, 2))

8. Бюджет видео и детерминированный план выборки

Контекст задаёт максимум кадров, а не только скалярный ответ. uniform_sample_indices возвращает реальные индексы, включая оба конца при выборе хотя бы двух кадров; для одного берётся первый. Для двух часов при 1 fps сравниваем 576 визуальных позиций на кадр и объединение 2×2 со 144 позициями на кадр, затем считаем KV фактически использованной последовательности.

Python
def uniform_sample_indices(total_frames: int, max_frames: int) -> np.ndarray:
    """Choose deterministic, approximately uniform frame indices including endpoints."""

    total_frames = _positive_int("total_frames", total_frames)
    max_frames = _positive_int("max_frames", max_frames)
    if max_frames >= total_frames:
        return np.arange(total_frames, dtype=np.int64)
    if max_frames == 1:
        return np.array([0], dtype=np.int64)
    indices = np.rint(np.linspace(0, total_frames - 1, max_frames)).astype(np.int64)
    if np.unique(indices).size != max_frames:
        raise AssertionError("uniform sampler produced duplicate indices")
    if indices[0] != 0 or indices[-1] != total_frames - 1:
        raise AssertionError("uniform sampler must include first and last frame")
    return indices



def video_budget(
    *,
    duration_seconds: float,
    source_fps: float,
    context_tokens: int,
    text_reserve_tokens: int,
    visual_tokens_per_frame: int,
    kv_spec: KVCacheSpec,
) -> dict[str, float | int]:
    """Compute frame capacity, sampling interval, and KV for one video prompt."""

    duration_seconds = _positive_finite("duration_seconds", duration_seconds)
    source_fps = _positive_finite("source_fps", source_fps)
    context_tokens = _positive_int("context_tokens", context_tokens)
    if not isinstance(text_reserve_tokens, (int, np.integer)) or isinstance(text_reserve_tokens, bool):
        raise ValueError("text_reserve_tokens must be an integer")
    text_reserve_tokens = int(text_reserve_tokens)
    if text_reserve_tokens < 0 or text_reserve_tokens >= context_tokens:
        raise ValueError("text_reserve_tokens must lie in [0, context_tokens)")
    visual_tokens_per_frame = _positive_int("visual_tokens_per_frame", visual_tokens_per_frame)
    total_frames = int(math.floor(duration_seconds * source_fps + 1e-12))
    if total_frames < 1:
        raise ValueError("the floor-based source-frame convention yields zero frames")
    visual_budget = context_tokens - text_reserve_tokens
    max_frames = visual_budget // visual_tokens_per_frame
    if max_frames <= 0:
        raise ValueError("the context budget cannot fit one frame")
    selected_frames = min(total_frames, max_frames)
    sample_interval_seconds = duration_seconds / selected_frames
    used_visual_tokens = selected_frames * visual_tokens_per_frame
    full_prompt_tokens = text_reserve_tokens + used_visual_tokens
    kv_bytes = full_prompt_tokens * kv_spec.bytes_per_token()
    return {
        "total_frames": total_frames,
        "visual_budget_tokens": visual_budget,
        "max_frames": max_frames,
        "selected_frames": selected_frames,
        "sample_interval_seconds": sample_interval_seconds,
        "used_visual_tokens": used_visual_tokens,
        "visual_kv_gib": used_visual_tokens * kv_spec.bytes_per_token() / 2**30,
        "full_prompt_tokens": full_prompt_tokens,
        "full_prompt_kv_gib": kv_bytes / 2**30,
    }



V = REFERENCE["video"]
plain = video_budget(duration_seconds=7200, source_fps=1.0, context_tokens=256_000, text_reserve_tokens=4_000, visual_tokens_per_frame=576, kv_spec=spec)
merged_video = video_budget(duration_seconds=7200, source_fps=1.0, context_tokens=256_000, text_reserve_tokens=4_000, visual_tokens_per_frame=144, kv_spec=spec)
for key, value in V["without_merger"].items():
    assert math.isclose(plain[key], value, rel_tol=0, abs_tol=1e-12)
for key, value in V["with_merger_2x2"].items():
    assert math.isclose(merged_video[key], value, rel_tol=0, abs_tol=1e-12)
indices = uniform_sample_indices(plain["total_frames"], plain["selected_frames"])
indices_merged = uniform_sample_indices(merged_video["total_frames"], merged_video["selected_frames"])
assert len(indices) == 437 and len(indices_merged) == 1750
assert indices[0] == indices_merged[0] == 0 and indices[-1] == indices_merged[-1] == 7199
print('Вместимость видео, интервал выборки, индексы кадров и KV полного запроса проверены')
print(f"plain: {len(indices)} frames | 1/{plain['sample_interval_seconds']:.1f}s | visual {plain['visual_kv_gib']:.2f} GiB | full {plain['full_prompt_kv_gib']:.2f} GiB")
print(f"merge: {len(indices_merged)} frames | 1/{merged_video['sample_interval_seconds']:.1f}s | visual {merged_video['visual_kv_gib']:.2f} GiB | full {merged_video['full_prompt_kv_gib']:.2f} GiB")
print("first/last sampled IDs:", indices[:4].tolist(), "...", indices[-4:].tolist())
print("inverse density / mean timestamp gap (s):",
      plain['sample_interval_seconds'], float(np.diff(indices).mean()),
      merged_video['sample_interval_seconds'], float(np.diff(indices_merged).mean()))

9. Негативные тесты контрактов

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

Python
def expect_error(label, fn, error_type=ValueError):
    """Run one negative test and require the declared exception type."""

    try:
        fn()
    except error_type:
        print("expected:", label)
        return
    raise AssertionError(f"{label}: expected {error_type.__name__}")


expect_error("resolution not divisible by patch", lambda: patch_layout(335, 336))
expect_error("grid not divisible by merger", lambda: patch_layout(42, 28, merge_factor=2))
expect_error("token count/grid mismatch", lambda: merge_spatial_tokens(np.zeros((7, 3)), (4, 2)))
expect_error("projector feature width mismatch", lambda: project_vision(np.zeros((1, 2, 7)), weights))
expect_error("zero image placeholders", lambda: assemble_multimodal_sequence(np.array([1, 2]), np.zeros((2, 16)), image_placeholder_id=999, image_embeddings=visual_embeddings[0], image_start_embedding=np.zeros(16), image_end_embedding=np.zeros(16)))
expect_error("two image placeholders", lambda: assemble_multimodal_sequence(np.array([999, 999]), np.zeros((2, 16)), image_placeholder_id=999, image_embeddings=visual_embeddings[0], image_start_embedding=np.zeros(16), image_end_embedding=np.zeros(16)))
expect_error("non-positive InfoNCE temperature", lambda: symmetric_infonce(np.eye(2), 0.0))
expect_error("zero embedding row", lambda: l2_normalize_rows(np.array([[0.0, 0.0], [1.0, 0.0]])))
expect_error("no KV budget after weights", lambda: image_kv_metrics(576, spec, device_budget_gib=10.0, weights_gib=10.0))
expect_error("context cannot fit one frame", lambda: video_budget(duration_seconds=10, source_fps=1, context_tokens=100, text_reserve_tokens=90, visual_tokens_per_frame=20, kv_spec=spec))
print('Ожидаемые ошибки пойманы; молчаливое обрезание и скрытые NaN не проходят')

10. Пиксели → обученная модель → ответ

Два столбика, 32 обучающих изображения и 16 отложенных при другой яркости. 240 шагов, 16/4 визуальные позиции. Сравниваются два полных режима, а не только длина. Правильные ответы не передаются при генерации. Проверяем пустое изображение, причинную маску, градиент через замороженный декодер и загрузку весов. Это перенос яркости знакомых композиций; по нему нельзя судить о качестве на фотографиях.

Python
"""A small, fully trained pixels-to-token model; no pretrained VLM or external data.

Images are generated by this module. Held-out images change rendering brightness,
not the underlying bar-height combinations. This tests rendering transfer only.
"""
import io
import math
import torch
from torch import nn
from torch.nn import functional as F


def bar_data():
    """Return disjoint 8x8 grayscale renders of two bars with heights 1..4."""
    images, labels, split = [], [], []
    for brightness in (0.6, 1.0, 0.8):
        for left in range(1, 5):
            for right in range(1, 5):
                image = torch.zeros(1, 8, 8)
                image[0, 7-left:7, 1:3] = brightness
                image[0, 7-right:7, 5:7] = brightness
                images.append(image)
                # IDs: prompt=0, left=1, equal=2, right=3, EOS=4.
                labels.append([1 if left > right else 3 if right > left else 2, 4])
                split.append(brightness == 0.8)
    x, y, test = torch.stack(images), torch.tensor(labels), torch.tensor(split)
    assert len({row.numpy().tobytes() for row in x}) == 48
    return x[~test], y[~test], x[test], y[test]


class TinyVLM(nn.Module):
    """Patch encoder -> MLP connector -> one causal decoder -> answer and EOS.

    merge=2 uses lossless pixel unshuffle BEFORE the learned encoder: it changes
    the number of positions, not the number of observed pixels. This is not a ViT.
    """
    def __init__(self, merge=1):
        super().__init__()
        if type(merge) is not int or merge not in (1, 2):
            raise ValueError("merge must be 1 or 2")
        self.merge = merge
        self.n_visual = 16 // merge**2
        self.encoder = nn.Linear(4 * merge**2, 16)
        self.projector = nn.Sequential(nn.Linear(16, 32), nn.GELU(), nn.Linear(32, 32))
        self.embedding = nn.Embedding(5, 32)
        self.position = nn.Parameter(torch.randn(1, self.n_visual + 3, 32) * 0.02)
        self.norm1 = nn.LayerNorm(32)
        self.qkv = nn.Linear(32, 96)
        self.out = nn.Linear(32, 32)
        self.norm2 = nn.LayerNorm(32)
        self.ff = nn.Sequential(nn.Linear(32, 64), nn.GELU(), nn.Linear(64, 32))
        self.head = nn.Linear(32, 5)

    def forward(self, images, text):
        # unfold: [B,C,H,W] -> [B,16,4], row-major 2x2 patches.
        b = len(images)
        patches = F.unfold(images, kernel_size=2, stride=2).transpose(1, 2)
        if self.merge == 2:
            patches = patches.reshape(b, 2, 2, 2, 2, 4).permute(0, 1, 3, 2, 4, 5).reshape(b, 4, 16)
        visual = self.projector(torch.tanh(self.encoder(patches)))
        x = torch.cat([visual, self.embedding(text)], dim=1)
        x = x + self.position[:, :x.shape[1]]
        q, k, v = self.qkv(self.norm1(x)).chunk(3, dim=-1)
        scores = q @ k.transpose(-1, -2) / math.sqrt(32)
        allowed = torch.ones(x.shape[1], x.shape[1], dtype=torch.bool, device=x.device).tril()
        attention = scores.masked_fill(~allowed, float('-inf')).softmax(-1)
        x = x + self.out(attention @ v)
        x = x + self.ff(self.norm2(x))
        return self.head(x)

    def loss(self, images, answers):
        # Prompt predicts answer; answer predicts EOS. Exactly one causal shift.
        text = torch.cat([torch.zeros(len(images), 1, dtype=torch.long), answers[:, :1]], dim=1)
        logits = self(images, text)[:, self.n_visual:]
        return F.cross_entropy(logits.reshape(-1, 5), answers.reshape(-1))

    @torch.no_grad()
    def generate(self, images):
        text = torch.zeros(len(images), 1, dtype=torch.long)
        for _ in range(2):
            token = self(images, text)[:, -1].argmax(-1, keepdim=True)
            text = torch.cat([text, token], dim=1)
        return text[:, 1:]


def run_vision_lab(steps=240):
    """Train two declared models, save/reload, and return stored predictions."""
    if type(steps) is not int or steps <= 0:
        raise ValueError("steps must be a positive integer")
    torch.set_num_threads(1)
    torch.use_deterministic_algorithms(True)
    train_x, train_y, test_x, test_y = bar_data()
    metrics, records = {}, {}
    for merge in (1, 2):
        torch.manual_seed(1600 + merge)
        model = TinyVLM(merge)
        initial = float(model.loss(test_x, test_y).detach())
        optimizer = torch.optim.AdamW(model.parameters(), lr=0.005, weight_decay=0.01)
        losses = []
        for _ in range(steps):
            loss = model.loss(train_x, train_y)
            optimizer.zero_grad(set_to_none=True)
            loss.backward()
            assert all(p.grad is not None and torch.isfinite(p.grad).all() for p in model.parameters())
            optimizer.step()
            losses.append(float(loss.detach()))
        model.eval()
        predictions = model.generate(test_x)
        blank_predictions = model.generate(torch.zeros_like(test_x))
        train_predictions = model.generate(train_x)
        # The first answer logit cannot see the teacher-forced answer token.
        a = torch.zeros(len(test_x), 2, dtype=torch.long)
        b = a.clone(); b[:, 1] = 3
        with torch.no_grad():
            torch.testing.assert_close(model(test_x, a)[:, model.n_visual], model(test_x, b)[:, model.n_visual])
        # Serialization must preserve the model's actual outputs.
        buffer = io.BytesIO()
        torch.save(model.state_dict(), buffer)
        buffer.seek(0)
        restored = TinyVLM(merge)
        restored.load_state_dict(torch.load(buffer, weights_only=True))
        restored.eval()
        assert torch.equal(restored.generate(test_x), predictions)
        # Freezing the decoder must NOT cut the input gradient to the projector.
        for name, p in model.named_parameters():
            p.requires_grad_(name.startswith('projector.'))
            p.grad = None
        model.loss(train_x, train_y).backward()
        assert sum(float(p.grad.abs().sum()) for p in model.projector.parameters()) > 0
        assert all(p.grad is None for name, p in model.named_parameters() if not name.startswith('projector.'))
        key = str(merge)
        metrics[key] = dict(visual_tokens=model.n_visual, parameters=sum(p.numel() for p in model.parameters()),
            train_images=len(train_x), test_images=len(test_x), steps=steps,
            initial_test_ce=initial, final_test_ce=float(model.loss(test_x, test_y).detach()),
            train_correct=int((train_predictions == train_y).all(-1).sum()),
            exact_correct=int((predictions == test_y).all(-1).sum()),
            blank_correct=int((blank_predictions == test_y).all(-1).sum()))
        records[key] = dict(predictions=predictions.tolist(), targets=test_y.tolist(),
                            blank_predictions=blank_predictions.tolist(), loss=losses)
    return metrics, records



vision_metrics, vision_records = run_vision_lab()
print(json.dumps(vision_metrics, indent=2))

Сопоставьте train/test и пустую картинку. Различие качества нельзя объяснить потерей пикселей при конкатенации: она обратима; меняются обучение и последующая проекция. Гиперпараметры не подбираются по этим отложенным результатам.

11. Аудио: от отсчётов к позициям и кодам

Шесть секунд синтетического сигнала при 16 kHz позволяют проверить формы без записи речи и внешних моделей. Окна длиной 400 начинаются через 160 отсчётов; последние дополняются справа. Модули спектра дают векторы ширины 201, а усреднение четырёх кадров уменьшает частоту позиций. Следом отдельный расчёт считает дискретные коды при 75 кадрах/с и восьми книгах. Эти две частоты принадлежат разным сценариям.

Малый RVQ-пример выбирает записи из двух заданных вручную книг. Посмотрите, как вторая запись уточняет остаток первой. Это работа квантователя над векторами, а не обученный речевой кодек; восстановление волновой формы здесь не реализуется.

Python
"""Audio framing, temporal pooling and a tiny residual quantizer, all on CPU.

The spectra and hand-written codebooks are teaching examples, not a speech model
or a trained audio codec. Codec-rate accounting is a separate declared scenario.
"""
from numbers import Integral
import math
import numpy as np


def count(value, name):
    if isinstance(value, bool) or not isinstance(value, Integral) or value <= 0:
        raise ValueError(name + ' must be a positive integer')
    return int(value)


def frame_signal(waveform, window=400, hop=160):
    """Start a window at 0, hop, ... < length; right-pad the last windows."""
    signal = np.asarray(waveform)
    window, hop = count(window, 'window'), count(hop, 'hop')
    if signal.ndim != 1 or signal.size == 0 or signal.dtype.kind not in 'fiu':
        raise ValueError('waveform must be a nonempty real numeric vector')
    if not np.isfinite(signal).all():
        raise ValueError('waveform must be finite')
    starts = np.arange(0, len(signal), hop)
    indices = starts[:, None] + np.arange(window)[None, :]
    valid = indices < len(signal)
    frames = np.zeros(indices.shape, dtype=np.float64)
    frames[valid] = signal[indices[valid]]
    return frames, valid


def pool_frames(features, factor=4):
    """Average consecutive frame vectors; the final short group uses its own size."""
    x = np.asarray(features, dtype=np.float64)
    factor = count(factor, 'factor')
    if x.ndim != 2 or 0 in x.shape or not np.isfinite(x).all():
        raise ValueError('features must be a finite nonempty matrix')
    return np.stack([x[i:i+factor].mean(axis=0) for i in range(0, len(x), factor)])


def residual_quantize(features, codebooks):
    """Greedily encode residuals; books have shape [books, entries, width]."""
    x = np.asarray(features, dtype=np.float64)
    books = np.asarray(codebooks, dtype=np.float64)
    if x.ndim != 2 or 0 in x.shape or books.ndim != 3 or 0 in books.shape:
        raise ValueError('nonempty feature matrix and codebook tensor required')
    if x.shape[1] != books.shape[2] or not np.isfinite(x).all() or not np.isfinite(books).all():
        raise ValueError('widths must agree and values must be finite')
    residual = x.copy()
    reconstructed = np.zeros_like(x)
    ids, squared_errors = [], [np.square(residual).sum(axis=1)]
    for book in books:
        distances = np.square(residual[:, None, :] - book[None, :, :]).sum(axis=-1)
        chosen = distances.argmin(axis=1)
        reconstructed += book[chosen]
        residual -= book[chosen]
        ids.append(chosen)
        squared_errors.append(np.square(residual).sum(axis=1))
    return np.stack(ids, axis=1), reconstructed, np.stack(squared_errors, axis=1)


def audio_budget(n_samples=96000, sample_rate=16000, hop=160, pooling=4,
                 codec_rate=75, books=8, entries=1024):
    """Two separate paths: acoustic features and illustrative discrete codec codes."""
    n_samples, sample_rate, hop, pooling, codec_rate, books, entries = (
        count(v,n) for v,n in zip((n_samples,sample_rate,hop,pooling,codec_rate,books,entries),
                                ('samples','sample_rate','hop','pooling','codec_rate','books','entries')))
    if entries < 2 or entries & (entries-1):
        raise ValueError('entries must be a power of two >= 2 for fixed-width packing')
    frames = (n_samples+hop-1)//hop
    codec_frames = (n_samples*codec_rate+sample_rate-1)//sample_rate
    ids = codec_frames*books
    return dict(samples=n_samples, seconds=n_samples/sample_rate,
                acoustic_frames=frames, pooled_positions=(frames+pooling-1)//pooling,
                codec_frames=codec_frames, codec_ids=ids,
                codec_payload_bits=ids*(entries.bit_length()-1),
                codec_bits_per_second=codec_rate*books*(entries.bit_length()-1))


def run_audio_example():
    t = np.arange(96000)/16000
    wave = .6*np.sin(2*np.pi*220*t) + .2*np.sin(2*np.pi*660*t)
    frames, valid = frame_signal(wave)
    # Simple magnitude spectra: not log-mel features or a trained audio encoder.
    features = np.abs(np.fft.rfft(frames*np.hanning(400), axis=1))
    pooled = pool_frames(features)
    assert frames.shape == (600,400) and features.shape == (600,201)
    assert pooled.shape == (150,201) and valid[-1].sum() == 160
    budget = audio_budget()
    assert budget['acoustic_frames'] == len(frames)
    assert budget['pooled_positions'] == len(pooled)
    books = np.array([[[0,0],[1,0],[0,-1]], [[0,0],[.5,0],[.5,-.5]]], dtype=float)
    ids, reconstructed, errors = residual_quantize([[1.6,-.6]], books)
    assert ids.tolist() == [[1,2]]
    np.testing.assert_allclose(reconstructed, [[1.5,-.5]], atol=1e-15)
    np.testing.assert_allclose(errors, [[2.92,.72,.02]], atol=1e-15)
    # Zero is available in each book, so this toy can never increase residual norm.
    assert np.all(np.diff(errors, axis=1) <= 0)
    # Illustrative timings; no speech decoder is measured by this script.
    rtf = 2.4/6
    assert math.isclose(rtf,.4)
    result = dict(budget=budget, spectral_shape=list(features.shape),
                  pooled_shape=list(pooled.shape), rvq_ids=ids.tolist(),
                  rvq_squared_errors=errors.tolist(), illustrative_rtf=rtf,
                  illustrative_first_audio_seconds=.18)
    return result



audio_result = run_audio_example()
print(json.dumps(audio_result, indent=2))

Сравните три числа: 600 акустических кадров, 150 позиций после усреднения и 3600 индексов в отдельном кодек-сценарии. Умножать последнее на цену обычного KV можно только после определения схемы передачи кодов декодеру. RTF=0.4 и задержка первого пакета 0.18 с — заданные для иллюстрации времена, а не замеры этого ноутбука.

Для самостоятельной проверки измените длину записи на 96 001 отсчёт. Должны получиться 601 кадр и 151 усреднённая позиция. Объясните, почему последний добавленный отсчёт изменил оба счётчика и почему неполную группу нельзя делить на четыре.

Упражнения

  1. [авто] Добавьте дополнение (padding) до ближайшего кратного patch_size и верните pixel_mask, чтобы добавленные пиксели не считались реальными.

  2. [авто] Обобщите assemble_multimodal_sequence на два placeholder <image> и проверьте порядок двух независимых визуальных блоков.

  3. [авто] Реализуйте обучаемое объединение: после конкатенации m²D примените линейную проекцию обратно в D и сравните число параметров для m∈{1,2,4}.

  4. [авто] Постройте temperature → InfoNCE и численно найдите температуру, при которой симметричная функция потерь равна 0.1 для заданной матрицы сходств.

  5. [проект] Замените синтетические визуальные признаки на признаки небольшого открытого визуального энкодера и проверьте тот же контракт форм и сборки на одном изображении.


Обязательный маршрут работает на CPU и не требует файлов репозитория. Это полный учебный путь через механику M16, а не готовая к развёртыванию система мультимодального инференса.

Демо проектных упражнений модуля 16 — Обучаемая VLM и маршрутизация её ответов

Проектная демонстрация

Скачать .ipynb module_16_project_demo.ipynb · 18,2 КБ

Показать текст ноутбука

Демо проектных упражнений модуля 16 — Обучаемая VLM и маршрутизация её ответов

Демо-реализация, не готовое решение полномасштабного проекта. Изображения двух столбиков кодируются непосредственно из пикселей. Обучаем две маленькие сети с разным числом позиций, проверяем генерацию и пустое изображение. Затем превращаем сохранённые ответы в разрешённые отметки на учебной схеме. Внешних API и действий нет. Все входы малы, детерминированы и работают без сети на CPU. Цель — проверить интерфейсы, метрики, инварианты и структуру отчёта до расходования GPU/API-бюджета.

Python
from __future__ import annotations
import importlib.util, subprocess, sys
for package in ("numpy", "matplotlib"):
    if importlib.util.find_spec(package) is None:
        subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", package])


import math
import random
import time
from collections import Counter, defaultdict

import matplotlib.pyplot as plt
import numpy as np

SEED = 20260801
random.seed(SEED)
np.random.seed(SEED)
print(f"воспроизводимый тестовый запуск; seed={SEED}")
DEMO_LANGUAGE = 'ru'

Проверьте, как создаются данные: все 16 пар высот встречаются в обучении при яркости 0.6 и 1.0, а при оценке — при яркости 0.8. Сети получают пиксели и постоянный запрос, а не численные высоты. Обе обучаются 240 шагов; режим с четырьмя позициями сначала объединяет соседние патчи. На каждом шаге запрос предсказывает ответ, а ответ — EOS. Сопоставьте этот сдвиг с позициями функции потерь.

Python
import importlib.util, subprocess, sys, json
if importlib.util.find_spec("torch") is None:
    subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "torch"])
"""A small, fully trained pixels-to-token model; no pretrained VLM or external data.

Images are generated by this module. Held-out images change rendering brightness,
not the underlying bar-height combinations. This tests rendering transfer only.
"""
import io
import math
import torch
from torch import nn
from torch.nn import functional as F


def bar_data():
    """Return disjoint 8x8 grayscale renders of two bars with heights 1..4."""
    images, labels, split = [], [], []
    for brightness in (0.6, 1.0, 0.8):
        for left in range(1, 5):
            for right in range(1, 5):
                image = torch.zeros(1, 8, 8)
                image[0, 7-left:7, 1:3] = brightness
                image[0, 7-right:7, 5:7] = brightness
                images.append(image)
                # IDs: prompt=0, left=1, equal=2, right=3, EOS=4.
                labels.append([1 if left > right else 3 if right > left else 2, 4])
                split.append(brightness == 0.8)
    x, y, test = torch.stack(images), torch.tensor(labels), torch.tensor(split)
    assert len({row.numpy().tobytes() for row in x}) == 48
    return x[~test], y[~test], x[test], y[test]


class TinyVLM(nn.Module):
    """Patch encoder -> MLP connector -> one causal decoder -> answer and EOS.

    merge=2 uses lossless pixel unshuffle BEFORE the learned encoder: it changes
    the number of positions, not the number of observed pixels. This is not a ViT.
    """
    def __init__(self, merge=1):
        super().__init__()
        if type(merge) is not int or merge not in (1, 2):
            raise ValueError("merge must be 1 or 2")
        self.merge = merge
        self.n_visual = 16 // merge**2
        self.encoder = nn.Linear(4 * merge**2, 16)
        self.projector = nn.Sequential(nn.Linear(16, 32), nn.GELU(), nn.Linear(32, 32))
        self.embedding = nn.Embedding(5, 32)
        self.position = nn.Parameter(torch.randn(1, self.n_visual + 3, 32) * 0.02)
        self.norm1 = nn.LayerNorm(32)
        self.qkv = nn.Linear(32, 96)
        self.out = nn.Linear(32, 32)
        self.norm2 = nn.LayerNorm(32)
        self.ff = nn.Sequential(nn.Linear(32, 64), nn.GELU(), nn.Linear(64, 32))
        self.head = nn.Linear(32, 5)

    def forward(self, images, text):
        # unfold: [B,C,H,W] -> [B,16,4], row-major 2x2 patches.
        b = len(images)
        patches = F.unfold(images, kernel_size=2, stride=2).transpose(1, 2)
        if self.merge == 2:
            patches = patches.reshape(b, 2, 2, 2, 2, 4).permute(0, 1, 3, 2, 4, 5).reshape(b, 4, 16)
        visual = self.projector(torch.tanh(self.encoder(patches)))
        x = torch.cat([visual, self.embedding(text)], dim=1)
        x = x + self.position[:, :x.shape[1]]
        q, k, v = self.qkv(self.norm1(x)).chunk(3, dim=-1)
        scores = q @ k.transpose(-1, -2) / math.sqrt(32)
        allowed = torch.ones(x.shape[1], x.shape[1], dtype=torch.bool, device=x.device).tril()
        attention = scores.masked_fill(~allowed, float('-inf')).softmax(-1)
        x = x + self.out(attention @ v)
        x = x + self.ff(self.norm2(x))
        return self.head(x)

    def loss(self, images, answers):
        # Prompt predicts answer; answer predicts EOS. Exactly one causal shift.
        text = torch.cat([torch.zeros(len(images), 1, dtype=torch.long), answers[:, :1]], dim=1)
        logits = self(images, text)[:, self.n_visual:]
        return F.cross_entropy(logits.reshape(-1, 5), answers.reshape(-1))

    @torch.no_grad()
    def generate(self, images):
        text = torch.zeros(len(images), 1, dtype=torch.long)
        for _ in range(2):
            token = self(images, text)[:, -1].argmax(-1, keepdim=True)
            text = torch.cat([text, token], dim=1)
        return text[:, 1:]


def run_vision_lab(steps=240):
    """Train two declared models, save/reload, and return stored predictions."""
    if type(steps) is not int or steps <= 0:
        raise ValueError("steps must be a positive integer")
    torch.set_num_threads(1)
    torch.use_deterministic_algorithms(True)
    train_x, train_y, test_x, test_y = bar_data()
    metrics, records = {}, {}
    for merge in (1, 2):
        torch.manual_seed(1600 + merge)
        model = TinyVLM(merge)
        initial = float(model.loss(test_x, test_y).detach())
        optimizer = torch.optim.AdamW(model.parameters(), lr=0.005, weight_decay=0.01)
        losses = []
        for _ in range(steps):
            loss = model.loss(train_x, train_y)
            optimizer.zero_grad(set_to_none=True)
            loss.backward()
            assert all(p.grad is not None and torch.isfinite(p.grad).all() for p in model.parameters())
            optimizer.step()
            losses.append(float(loss.detach()))
        model.eval()
        predictions = model.generate(test_x)
        blank_predictions = model.generate(torch.zeros_like(test_x))
        train_predictions = model.generate(train_x)
        # The first answer logit cannot see the teacher-forced answer token.
        a = torch.zeros(len(test_x), 2, dtype=torch.long)
        b = a.clone(); b[:, 1] = 3
        with torch.no_grad():
            torch.testing.assert_close(model(test_x, a)[:, model.n_visual], model(test_x, b)[:, model.n_visual])
        # Serialization must preserve the model's actual outputs.
        buffer = io.BytesIO()
        torch.save(model.state_dict(), buffer)
        buffer.seek(0)
        restored = TinyVLM(merge)
        restored.load_state_dict(torch.load(buffer, weights_only=True))
        restored.eval()
        assert torch.equal(restored.generate(test_x), predictions)
        # Freezing the decoder must NOT cut the input gradient to the projector.
        for name, p in model.named_parameters():
            p.requires_grad_(name.startswith('projector.'))
            p.grad = None
        model.loss(train_x, train_y).backward()
        assert sum(float(p.grad.abs().sum()) for p in model.projector.parameters()) > 0
        assert all(p.grad is None for name, p in model.named_parameters() if not name.startswith('projector.'))
        key = str(merge)
        metrics[key] = dict(visual_tokens=model.n_visual, parameters=sum(p.numel() for p in model.parameters()),
            train_images=len(train_x), test_images=len(test_x), steps=steps,
            initial_test_ce=initial, final_test_ce=float(model.loss(test_x, test_y).detach()),
            train_correct=int((train_predictions == train_y).all(-1).sum()),
            exact_correct=int((predictions == test_y).all(-1).sum()),
            blank_correct=int((blank_predictions == test_y).all(-1).sum()))
        records[key] = dict(predictions=predictions.tolist(), targets=test_y.tolist(),
                            blank_predictions=blank_predictions.tolist(), loss=losses)
    return metrics, records



plt.rcParams.update({"font.family": "DejaVu Sans", "figure.figsize": (11, 6),
    "figure.dpi": 150, "font.size": 12, "axes.titlesize": 17, "axes.labelsize": 13,
    "xtick.labelsize": 11, "ytick.labelsize": 11, "legend.fontsize": 11})
metrics, records = run_vision_lab()
print(json.dumps(metrics, indent=2))
for key, record in records.items():
    plt.semilogy(record["loss"], label=str(metrics[key]["visual_tokens"]) +
                 (" визуальных позиций" if DEMO_LANGUAGE == "ru" else " visual positions"))
plt.xlabel("Шаг оптимизации" if DEMO_LANGUAGE == "ru" else "Optimization step")
plt.ylabel("Обучающая кросс-энтропия" if DEMO_LANGUAGE == "ru" else "Training cross-entropy")
plt.legend(); plt.tight_layout(); plt.show()

На данном seed режим с 16 позициями правильно отвечает на 16 из 16 отложенных изображений, а с четырьмя — на 6. Пустые изображения дают 4 и 6 правильных ответов соответственно. Поэтому во втором режиме одной допустимой формы ответа недостаточно, чтобы подтвердить использование зрения. Конкатенация сама по себе не теряет пикселей; различаются параметры и ход обучения. Для вывода об архитектуре нужны повторные запуски и согласованные условия.

Второй опыт читает уже сохранённые ответы первой части. Допустимы только последовательности из одного ответа left/equal/right и EOS; всё остальное ведёт к уточнению. Разрешённое действие здесь лишь создаёт запись о метке на схеме. Отделим проверку формы ответа от проверки того, правильно ли модель поняла изображение.

Python
# Consume saved predictions; routing and scoring never invoke the VLM again.
ALLOWED = {1: "mark_left", 2: "mark_equal", 3: "mark_right"}

def route_saved(tokens):
    if (not isinstance(tokens, list) or len(tokens) != 2
            or any(type(t) is not int for t in tokens)
            or tokens[1] != 4 or tokens[0] not in ALLOWED):
        return "clarify"
    return ALLOWED[tokens[0]]

traces = {}
for key, record in records.items():
    log = []
    for request_id, (prediction, target) in enumerate(zip(record["predictions"], record["targets"])):
        action = route_saved(prediction)
        # The mock tool only records an annotation; it has no external side effect.
        assert action in set(ALLOWED.values()) | {"clarify"}
        log.append(dict(request_id=request_id, prediction=prediction, action=action,
                        correct=action == route_saved(target)))
    assert len(log) == metrics[key]["test_images"] == 16
    assert sum(row["correct"] for row in log) == metrics[key]["exact_correct"]
    traces[key] = log
assert route_saved(["delete_files", 4]) == "clarify"
assert route_saved([True, 4]) == "clarify"
assert route_saved([1, 0]) == "clarify"
assert json.loads(json.dumps(traces)) == traces
print({key: {"recorded_actions": len(rows), "correct": sum(r["correct"] for r in rows)}
       for key, rows in traces.items()})

Все записанные действия входят в разрешённый набор, но это не делает их правильными. Число правильных отметок совпадает с числом верных сохранённых ответов: маршрутизатор не исправляет ошибку восприятия. Проверки отклоняют неизвестное действие, булев ID и неверное завершение; JSON сохраняет связь между запросом, ответом и отметкой.

Переход к полному проекту

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

Минимальный отчёт

  1. Версии данных, модели, кода и seed.

  2. Основная метрика, а также качество, стоимость, задержка и память там, где это применимо.

  3. Базовая линия, одинаковый бюджет и хотя бы один отрицательный контроль.

  4. Неудачные прогоны и границы обоснованных выводов.