Skip to content

Commit 3ad9d9a

Browse files
authored
Merge pull request #123 from KempnerInstitute/worktree-video-pipeline
Add video understanding to the VLM path
2 parents c3c0965 + 7641a09 commit 3ad9d9a

24 files changed

Lines changed: 2384 additions & 102 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
steps:
1414
- uses: actions/checkout@v5
1515
- uses: astral-sh/setup-uv@v6
16-
- run: uv sync
16+
- run: uv sync --group video
1717
- run: uv run ruff check kempnerforge/ tests/ scripts/
1818
- run: uv run ruff format --check kempnerforge/ tests/ scripts/
1919
- run: uv run pyright kempnerforge/
@@ -24,7 +24,7 @@ jobs:
2424
steps:
2525
- uses: actions/checkout@v5
2626
- uses: astral-sh/setup-uv@v6
27-
- run: uv sync
27+
- run: uv sync --group video
2828
- run: uv run pytest tests/unit/ -v --timeout=60 --cov --cov-branch --cov-report=xml
2929
- name: Upload coverage reports to Codecov
3030
uses: codecov/codecov-action@v5

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6060
- `scripts/train.py`: training-loop hook that runs `mot_warm_start_from_text_stack` once at step 0 when `mot_warm_start_from_text=True`, between `ckpt_mgr.load(...)` and `apply_freeze_specs(...)`.
6161
- Tests: `tests/unit/test_mot.py` (Algorithm-1 reference parity, warm-start helper round-trips); `tests/unit/test_model.py::TestMoT` + `TestModalityIdsCrossArgs`; MoT cases in `tests/unit/test_vlm.py`, `test_vlm_config.py`, `test_modality_context.py`; `tests/integration/test_vlm_mot.py`; `tests/distributed/test_vlm_mot_fsdp.py` (gated on multi-GPU).
6262
- Configs: `configs/train/vlm_debug_mot.toml` (1-GPU smoke) and `configs/train/vlm_7b_mot.toml` (4-GPU 7B).
63+
- **Video understanding (all four archs).** Extend the VLM path to ingest video — a clip is an ordered set of frames sampled at a target fps — through the same registry-driven design as the image path; trains end-to-end on WebVid-10M with Joint-Decoder, Cross-Attention, MoT, and MoMa. The text-only and single-image paths are unchanged (bit-exact).
64+
- **Pooling connector.** `kempnerforge/model/adapter.py`: a `VisionAdapter` base with an `output_num_tokens()` contract, plus `avgpool` and `attentional_pool` (Molmo2-style mean-query MHA) connectors registered via `@registry.register_adapter`; `kempnerforge/config/adapter.py` gains `pool_window` / `pool_heads`. The adapter-derived visual-token count is threaded through `build_vlm_wrapper` / `_build_vlm`, the four modality strategies, and the three seq-len checks (`config/job.py`, `distributed/parallel.py`, `model/vlm.py`). Projection adapters keep the count (identity) so the image path is bit-exact.
65+
- **Video data path (pluggable, registry-driven).** `kempnerforge/data/video_io.py`: timestamp-based frame sampling (target fps, first & last frame kept — Molmo2 §3.1/§A) registered as the `"uniform"` sampling policy and selectable via `[video].sampling_policy`; PyAV decode (lazy-imported). `kempnerforge/data/video_dataset.py`: a `VideoDataset` base + the WebVid-style `WebVidVideoDataset` (CSV manifest + `id[:2]/id[:4]/id[:6]/id.mp4` mapping) registered as `"webvid"` via `@registry.register_video_dataset`, plus a `build_video_dataset` dispatch — so other dataset styles are additive registrations selected by `[video].dataset_type`. The WebVid corpus directory is parameterized by `[video].dataset_name` (no longer hardcoded to `webvid-10M`). `VideoCollator` → `(B, F, 3, H, W)` + a frame-validity mask; an undecodable clip is masked out (no loss). `kempnerforge/config/registry.py`: `register_video_dataset` / `register_sampling_policy` registries. `kempnerforge/config/video.py`: the `[video]` `VideoConfig` section (`data_root`, `dataset_type`, `dataset_name`, `sampling_policy`, `split`, `fps`, `max_frames`, `min_frames`, `frame_size`, `max_samples`), wired into `JobConfig` (+ `is_video`). `av` is an optional `video` dependency group (`uv sync --group video`); CI installs it for the lint + unit-test jobs.
66+
- **Frame-aware model + training wiring.** `kempnerforge/model/vlm.py`: `_project_image_features``_project_visual_features` folds the frame axis through the encoder + pooler to `(B, F·P′, dim)` (a single image is the `F == 1` case). `VLMWrapper` gains `frames_per_clip`, threaded through `build_parallel_model` / `_build_vlm` / `build_vlm_wrapper` so the static visual-token count equals `F·P′` (drives the residual budget and MoT's positional split; static == runtime). `scripts/train.py` builds the video dataset/collator when `[video]` is set. Adds `configs/train/vlm_video_webvid.toml` (SigLIP2 + avgpool + WebVid).
67+
- Tests: `tests/unit/test_video_io.py`, `test_video_dataset.py`, `test_video_config.py`; video-forward cases (all four archs) + image-path regression in `test_vlm.py`; pooling-adapter cases in `test_adapter.py`. Docs: `docs/how-to/train-on-video.md`.
68+
- Deferred (follow-ups; the registries make these additive): more video dataset styles (HuggingFace video sets, flat folders, alternate manifests) and frame-sampling policies; per-frame timestamp tokens + grounding (`<points>`/`<tracks>` outputs with point-F1 / track-J&F eval), frame-mask-aware attention, bidirectional visual attention, VLM sequence packing, long-context (blocked on context-parallel being wired), and warm-start from a converted image-VLM checkpoint.
6369
- `install-and-verify` plugin skill: runs `uv sync`, asserts Python ≥ 3.12, then runs the four CI gate checks (`ruff check`, `ruff format --check`, `pyright`, `pytest tests/unit/`). Canonical first command after cloning.
6470
- `.python-version` pinned to `>=3.12` so uv resolves the interpreter explicitly. Teammates on 3.13 use 3.13 (no download); 3.11-only users get 3.12 auto-fetched.
6571
- **Dynamic-checkpointing window** (`[checkpoint.dyn_ckpt_window]`). Opt-in dense save phase: inside `[start, stop]` a registered strategy decides which steps to save; outside the window the regular `interval` cadence applies. The default strategy, `"power2"`, saves at `start` and at every `start + 2^k` while `<= stop` — tight near the start of the window, doubling thereafter. Useful for analyzing early-training dynamics, where the loss moves fastest. The default `CheckpointConfig` is unchanged (no `dyn_ckpt_window`, interval-only saves).

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,15 @@ Further reading:
141141

142142
### Vision-language models
143143

144-
KempnerForge supports VLM training via a thin wrapper around the existing `Transformer`. Image tokens come from a frozen HF vision encoder (SigLIP2, CLIP, or a tiny `random` test stub), pass through a 2-layer adapter, and feed the backbone via an arch-specific path:
144+
KempnerForge supports VLM training — images **or video** (a clip is an ordered set of frames) — via a thin wrapper around the existing `Transformer`. Visual tokens come from a frozen HF vision encoder (SigLIP2, CLIP, or a tiny `random` test stub), pass through a connector (a 2-layer adapter, or an `avgpool` / `attentional_pool` connector that pools patches to reduce tokens per frame), and feed the backbone via an arch-specific path:
145145

146146
- **Joint-Decoder** (`arch = "joint_decoder"`): image embeds are prepended to the text sequence; the transformer runs over the concatenated `(image, text)` sequence and the LM head is applied to text positions only.
147147
- **Cross-Attention** (`arch = "cross_attention"`, Llama-3-V style): the residual stream carries text only. Separate `CrossAttentionBlock`s inserted at a configurable cadence let text queries attend to image K/V. CA blocks are zero-initialized so adding the arch on top of a text-only checkpoint is identity at step 0 and learns from there.
148148
- **Mixture-of-Transformers** (`arch = "mot"`, Liang et al. 2024 Algorithm 1): every layer carries per-modality Q/K/V/O projections plus a per-modality FFN; a single global self-attention mixes all modality streams. Image tokens prepend the text sequence (same residual layout as Joint-Decoder); per-modality residual projections are zero-initialized so a fresh MoT block is identity at construction. A warm-start helper (`mot_warm_start_from_text_stack`) translates a JD or text-only checkpoint into per-modality copies — toggle via `[model.vlm].mot_warm_start_from_text` + `mot_warm_start_path`.
149149
- **Mixture of Modality-Aware Experts** (`arch = "moma"`, Lin et al. 2024 arXiv:2407.21770): one shared set of Q/K/V/O projections feeding a global self-attention, plus per-modality MoE FFN groups (paper's optimal default 4 image + 4 text experts per layer). Tokens route deterministically to their modality group (level-1, reusing the same `modality_ids` mechanism MoT uses) and then through a learned expert-choice + Sigmoid router within the group (level-2, with Gumbel-Sigmoid noise during training, paper Eq. 5). Image tokens prepend the text sequence (same residual layout as JD/MoT). v1 supports training only — expert-choice routing is non-causal, so autoregressive generation requires auxiliary routers (paper §2.4) which are deferred to a follow-up.
150150

151+
**Video** works across all four archs with no arch-specific changes: a clip is decoded into frames (by a registered `sampling_policy` — default `uniform`: by timestamp at a target fps, first and last frame kept), each frame is encoded and pooled by the connector, and the `F × tokens_per_frame` visual tokens enter the backbone exactly like image tokens. The data side is **pluggable**`[video].dataset_type` selects a registered dataset builder (`webvid` ships; `dataset_name` picks the corpus within a WebVid-style layout) and `sampling_policy` selects the frame-sampling policy, so new dataset styles / policies are additive registrations. Configure the `[video]` section (`data_root`, `dataset_type`, `dataset_name`, `sampling_policy`, `fps`, `max_frames`, `frame_size`); see `configs/train/vlm_video_webvid.toml`. Video decoding uses PyAV, an optional dependency — install it with `uv sync --group video`.
152+
151153
```bash
152154
# 1-GPU smoke (random encoder, Joint-Decoder)
153155
uv run python scripts/train.py configs/train/vlm_debug.toml \
@@ -164,9 +166,12 @@ uv run torchrun --nproc_per_node=4 scripts/train.py configs/train/vlm_7b_mot.tom
164166

165167
# 4-GPU 7B Mixture of Modality-Aware Experts (4 text + 4 image experts per layer)
166168
uv run torchrun --nproc_per_node=4 scripts/train.py configs/train/vlm_7b_moma.toml
169+
170+
# 4-GPU video training on WebVid (Joint-Decoder; flip [vlm].arch for cross_attention / mot / moma)
171+
uv run torchrun --nproc_per_node=4 scripts/train.py configs/train/vlm_video_webvid.toml
167172
```
168173

169-
Configs set `[model.vlm]` with `arch`, the encoder registry key, the number of image tokens, and a freeze list (`FreezeSpec`). For Cross-Attention, set `cross_attention_every_n_layers` and optionally `cross_attention_n_kv_heads` (0 → MHA; positive → GQA on the cross path). For MoT, set `mot_modalities` (must include both `"image"` and `"text"`); `mot_image_n_heads` / `mot_image_n_kv_heads` are forward-looking per-modality head fields (v1 enforces equality with the text-side counts since the operator runs a single global SDPA). For MoMa, set `moma_experts_per_modality = {image = N, text = M}` as a nested TOML table (the paper's optimal balanced default is `4t4i`; unbalanced allocations like `{image = 1, text = 7}` match the paper's `moe_7t1i` ablation), and optionally `moma_capacity_factor` (defaults to `1/|E^M|` per modality — the paper's perfect-balance setting) and `moma_gumbel_noise` (`true` by default for paper-faithful EC routing). `model.num_experts` must be `0` when `arch = "moma"`; the per-modality counts supersede it, and JobConfig.validate rejects the combination. The vision encoder stays in its HF-loaded dtype; the transformer, adapter, and CA / MoT / MoMa blocks are cast to `param_dtype`. Pipeline Parallel + VLM is not supported on this branch (raises at startup); MoMa + Expert Parallelism is also rejected in v1. Multi-image and video are reserved slots for follow-up work.
174+
Configs set `[model.vlm]` with `arch`, the encoder registry key, the number of image tokens, and a freeze list (`FreezeSpec`). For Cross-Attention, set `cross_attention_every_n_layers` and optionally `cross_attention_n_kv_heads` (0 → MHA; positive → GQA on the cross path). For MoT, set `mot_modalities` (must include both `"image"` and `"text"`); `mot_image_n_heads` / `mot_image_n_kv_heads` are forward-looking per-modality head fields (v1 enforces equality with the text-side counts since the operator runs a single global SDPA). For MoMa, set `moma_experts_per_modality = {image = N, text = M}` as a nested TOML table (the paper's optimal balanced default is `4t4i`; unbalanced allocations like `{image = 1, text = 7}` match the paper's `moe_7t1i` ablation), and optionally `moma_capacity_factor` (defaults to `1/|E^M|` per modality — the paper's perfect-balance setting) and `moma_gumbel_noise` (`true` by default for paper-faithful EC routing). `model.num_experts` must be `0` when `arch = "moma"`; the per-modality counts supersede it, and JobConfig.validate rejects the combination. The vision encoder stays in its HF-loaded dtype; the transformer, adapter, and CA / MoT / MoMa blocks are cast to `param_dtype`. Pipeline Parallel + VLM is not supported on this branch (raises at startup); MoMa + Expert Parallelism is also rejected in v1. Video is supported across all four archs via the `[video]` section (a clip is decoded into frames, pooled by the connector, and fed like image tokens); multi-image inputs and video *grounding* (point/track outputs with per-frame timestamps) are reserved for follow-up work.
170175

171176
**Adding a new VLM arch.** The discriminated-union dispatch is registry-driven, so a new arch is four small additions, no edits to existing call sites:
172177

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Video VLM training on WebVid-10M (Joint-Decoder).
2+
#
3+
# A clip is decoded into `max_frames` frames (sampled by timestamp at `fps`,
4+
# Molmo2-style: uniform, first + last frame kept), each encoded by the vision
5+
# tower, pooled by the connector, and prepended to the caption tokens. The LLM
6+
# is trained from scratch here; warm-starting from a pretrained backbone is a
7+
# later phase.
8+
#
9+
# Visual-token budget (Joint-Decoder): max_frames * tokens_per_frame + max_text_len
10+
# must be <= model.max_seq_len. With SigLIP2 @224/patch16 (14x14 = 196 patches)
11+
# and the avgpool connector at pool_window=2 -> ceil(14/2)^2 = 49 tokens/frame.
12+
# 8 frames -> 8*49 = 392 visual + 64 text = 456 <= 576.
13+
#
14+
# Launch (single node, 4 GPUs):
15+
# uv run torchrun --nproc_per_node=4 scripts/train.py configs/train/vlm_video_webvid.toml
16+
#
17+
# Quick smoke (no SigLIP download, a few clips; pair with a small step count):
18+
# ... --vision_encoder.type=random --vision_encoder.num_tokens=196 \
19+
# --vision_encoder.feature_dim=768 --video.max_samples=256 --train.max_steps=20
20+
21+
[model]
22+
dim = 1024
23+
n_layers = 12
24+
n_heads = 16
25+
n_kv_heads = 16
26+
vocab_size = 50257 # gpt2 tokenizer
27+
max_seq_len = 576 # 8 frames * 49 pooled tokens + 64 text + headroom
28+
norm_type = "rmsnorm"
29+
activation = "silu"
30+
rope_theta = 10000.0
31+
32+
[vision_encoder]
33+
type = "siglip2"
34+
path = "google/siglip2-base-patch16-224"
35+
# feature_dim / num_tokens left at 0 -> probed from the HF model at build time.
36+
37+
[adapter]
38+
type = "avgpool" # token-reducing connector; "attentional_pool" is the faithful upgrade
39+
pool_window = 2 # 14x14 patch grid -> 7x7 = 49 tokens/frame
40+
41+
[vlm]
42+
arch = "joint_decoder" # also: cross_attention / mot / moma
43+
max_text_len = 64
44+
freeze = [{module = "vision_encoder", frozen = true}]
45+
46+
[video]
47+
data_root = "/n/holylfs06/LABS/kempner_shared/Everyone/testbed/video/webvid-10m"
48+
split = "train"
49+
fps = 2.0
50+
max_frames = 8
51+
min_frames = 4
52+
frame_size = 224
53+
max_samples = 0 # 0 = full manifest; set small (e.g. 256) for a smoke
54+
55+
[data]
56+
tokenizer_path = "gpt2"
57+
num_workers = 4
58+
pin_memory = true
59+
60+
[train]
61+
batch_size = 4
62+
seq_len = 576
63+
max_steps = 5000
64+
grad_accum_steps = 4
65+
grad_clip_norm = 1.0
66+
seed = 42
67+
compile_model = false
68+
activation_checkpointing = "none"
69+
70+
[optimizer]
71+
name = "adamw"
72+
lr = 2e-4
73+
weight_decay = 0.1
74+
betas = [0.9, 0.95]
75+
fused = false
76+
77+
[scheduler]
78+
name = "cosine"
79+
warmup_steps = 200
80+
min_lr_ratio = 0.1
81+
82+
[distributed]
83+
dp_shard = -1
84+
85+
[checkpoint]
86+
dir = "checkpoints/vlm_video_webvid"
87+
interval = 1000
88+
keep_last_n = 2
89+
90+
[metrics]
91+
log_interval = 10
92+
enable_wandb = false
93+
enable_tensorboard = false

docs/how-to/index.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ with runnable code (or a link to a notebook, config, or script that runs it)
5252
`ActivationStore`, `extract_representations()`, save to `.npz`,
5353
feed to probing / CKA / SVCCA.
5454

55+
## Multimodal
56+
57+
- [Train on video](train-on-video.md) — ingest a clip as frames through the
58+
VLM path: timestamp frame sampling, the pooling connector, the `[video]`
59+
config + token budget, and how all four archs consume a clip.
60+
5561
```{toctree}
5662
:maxdepth: 1
5763
:hidden:
@@ -69,4 +75,5 @@ data-mixing-annealing
6975
fp8-training
7076
moe-experiments
7177
mechanistic-interpretability
78+
train-on-video
7279
```

0 commit comments

Comments
 (0)