Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9416d40
feat: systemic multimodal assistant-only loss masking + cfg.role_boun…
thad0ctor Apr 24, 2026
494fe3b
feat: multimodal CPT (raw image+text continued pre-training)
thad0ctor Apr 24, 2026
64970b3
feat: systemic multimodal assistant-only loss masking + cfg.role_boun…
thad0ctor Apr 24, 2026
9437bcd
Merge branch 'feat/multimodal-assistant-mask-all' of https://github.c…
thad0ctor Apr 24, 2026
ac37329
docs+types: address CodeRabbit nitpicks on PR #7
thad0ctor Apr 24, 2026
fb53a08
fix(mm-mask): address two CodeRabbit findings on PR #7
thad0ctor Apr 24, 2026
caf1445
doc cleanup
thad0ctor Apr 24, 2026
954794c
fix(mm-mask): CodeRabbit findings + lint fix on PR #3625
thad0ctor Apr 24, 2026
218018c
Merge pull request #7 from thad0ctor/feat/multimodal-assistant-mask-all
thad0ctor Apr 24, 2026
d76d66e
chore(mm-mask): hoist .tolist() out of scanner; shorten comments/docs…
thad0ctor Apr 24, 2026
d4fc169
Merge branch 'feat/multimodal-assistant-mask-all' into main
thad0ctor Apr 24, 2026
f4e609d
feat: multimodal CPT (raw image+text continued pre-training)
thad0ctor Apr 24, 2026
49503f5
Merge branch 'multimodal-cpt' of https://github.com/thad0ctor/axolotl…
thad0ctor Apr 24, 2026
c8d31b6
fix(mm-cpt): route test_datasets through streaming encoder so eval works
thad0ctor Apr 24, 2026
57ecee0
fix(mm-cpt): pass pretraining_config through and validate row length
thad0ctor Apr 24, 2026
e0f7923
fix(mm-cpt): make eval path actually work end-to-end
thad0ctor Apr 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions docs/multimodal.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,106 @@ Here is an example of a multi-modal dataset:
]
```

## Continued Pre-training (CPT) with images {#sec-multimodal-cpt}

Raw image+text continued pretraining — no chat template, no conversational
scaffolding. The model learns to emit raw text conditioned on visual patches.
Intended for use cases like OCR/transcription corpora where every row is a
tight `(image, target_text)` pair and any user/assistant framing would pollute
the learned signal.

### Dataset format (JSONL)

Two keys per row: `text` (the raw string) and `images` (list of local paths).
The `text` must contain the model's placeholder token **once per image**,
placed immediately before the text it describes, followed by a newline:

```json
{"text": "<image>\nפתאום מאימת שר ירושלים...", "images": ["/dataset/crops/doc_14_p2.png"]}
{"text": "<image>\nהגדולים למהרחיד\"א...", "images": ["/dataset/crops/doc_14_p3.png"]}
```

Notes:

- Never wrap the row in `User:` / `Assistant:` / `Transcribe this:` scaffolding — this is
the whole point of the CPT path.
- Do not manually append an EOS token. Axolotl appends one during tokenization.
- The newline between the placeholder and the real text preserves the BPE
boundary — without it, some tokenizers merge the visual-token boundary with
the first real character.

### The placeholder token varies by model

| Model family | Placeholder | Notes |
|---|---|---|
| LLaVA-1.5 / 1.6 | `<image>` | |
| SmolVLM / SmolVLM2 / Idefics3 | `<image>` | Processor expands to 1088 tokens (17 tiles × 64) |
| Qwen2-VL / Qwen2.5-VL / Qwen3-VL | `<\|image_pad\|>` | Processor autowraps with `<\|vision_start\|>` / `<\|vision_end\|>` |
| Gemma-3 | `<start_of_image>` | Processor expands to 256 `<image_soft_token>` |
| Gemma-4 | `<\|image\|>` | Processor expands to 256 `<\|image\|>` |

Axolotl autodetects the placeholder from the loaded processor. If autodetection
fails, supply `image_token: <your token>` on the dataset entry.

### YAML example

```yaml
base_model: HuggingFaceTB/SmolVLM-500M-Instruct
processor_type: AutoProcessor

pretraining_dataset:
- path: /path/to/shards/*.jsonl
ds_type: json
type: multimodal_pretrain
text_column: text
image_column: images
image_base_dir: /path/to/images # optional, for relative paths
# image_token: "<image>" # optional override; autodetect by default

streaming: true
sequence_len: 2048
sample_packing: false # REQUIRED — see below
remove_unused_columns: false # auto-set by validator

max_steps: 10000
micro_batch_size: 1
gradient_accumulation_steps: 8
```

### Gates and rejections

The following combinations are rejected at config-load time with a clear error:

- `sample_packing: true` — cross-row packing would break the 1-to-1 alignment
between text placeholders and `pixel_values`.
- `chat_template` set to anything — defeats the purpose of the CPT path.
- `processor_type` unset — no processor means no image tensors.

In addition, the following model families are **not supported** in v1 and will
be rejected when their processor is loaded:

- **Llama-3.2-Vision (Mllama)** — uses cross-attention image injection, not
in-stream placeholders. Use chat-template SFT.
- **Pixtral** — requires `mistral_common` and a different API.
- **InternVL** — ships a custom processor that doesn't produce `pixel_values`.

Per-row validation: at encode time the row's text is tokenized once and the
number of `image_token_id` occurrences in the resulting token-id list must
equal `len(images)`. Counting by token id (not by substring) avoids false
matches — e.g., `<image>` would substring-match inside `<image_soft_token>`.
This is a critical guardrail — LLaVA and Qwen-VL processors silently
accept rows without placeholders and drop the image, which looks like
successful training but teaches nothing. If a row fails this check,
inspect the tokenized ids rather than the raw string.

### Why masking image tokens in labels is automatic

The patch masks every image-family token id (`<image>`, `<\|image_pad\|>`,
`<\|vision_start\|>`, `<\|vision_end\|>`, `<start_of_image>`, `<end_of_image>`,
`<image_soft_token>`, `<\|image\|>`, etc.) to `-100` in the labels tensor.
Without this, loss is ~10× higher and training diverges — the model is
forced to predict tokens that correspond to patch embeddings, not real text.

## FAQ

1. `PIL.UnidentifiedImageError: cannot identify image file ...`
Expand Down
72 changes: 72 additions & 0 deletions src/axolotl/core/builders/causal.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,37 @@
V2BatchSamplerDataCollatorForSeq2Seq,
)
from axolotl.utils.collators.mm_chat import MultiModalChatDataCollator
from axolotl.utils.collators.mm_pretrain import MultiModalPretrainDataCollator
from axolotl.utils.import_helper import get_cls_from_module_str
from axolotl.utils.logging import get_logger

LOG = get_logger(__name__)


def _is_multimodal_cpt(cfg) -> bool:
"""True iff this config is a raw image+text CPT run (no chat template)."""
if not getattr(cfg, "pretraining_dataset", None):
return False
ds_first = cfg.pretraining_dataset[0]
ds_type = None
mm_flag = None
if hasattr(ds_first, "type"):
ds_type = getattr(ds_first, "type", None)
mm_flag = getattr(ds_first, "multimodal", None)
elif isinstance(ds_first, dict):
ds_type = ds_first.get("type")
mm_flag = ds_first.get("multimodal")
return (ds_type == "multimodal_pretrain") or bool(mm_flag)


def _mm_cpt_get(pt_cfg, key, default=None):
"""Read a field from a pretraining_dataset entry that may be dict, pydantic
model, or DictDefault."""
if isinstance(pt_cfg, dict):
return pt_cfg.get(key, default)
return getattr(pt_cfg, key, default)


class HFCausalTrainerBuilder(TrainerBuilderBase):
"""
Build the HuggingFace training args/trainer for causal models and reward modeling
Expand Down Expand Up @@ -451,13 +476,51 @@ def build(self, total_num_steps):

return trainer

def _build_mm_pretrain_collator(self, pad_to_multiple_of=None):
"""Construct the multimodal CPT collator with pt_cfg-derived spec
and image_base_dir. Shared between the pretraining and non-pretraining
dispatch branches in `build_collator`."""
from axolotl.prompt_strategies.multimodal_pretrain import (
build_image_token_spec,
)

pt_cfg = self.cfg.pretraining_dataset[0] if self.cfg.pretraining_dataset else {}
spec = build_image_token_spec(
self.processor, override=_mm_cpt_get(pt_cfg, "image_token")
)
collator_kwargs = {
"tokenizer": self.tokenizer,
"processor": self.processor,
"image_token_spec": spec,
"image_base_dir": _mm_cpt_get(pt_cfg, "image_base_dir"),
"max_length": self.cfg.sequence_len,
}
if pad_to_multiple_of is not None:
collator_kwargs["pad_to_multiple_of"] = pad_to_multiple_of
return MultiModalPretrainDataCollator(**collator_kwargs)

def build_collator(
self,
training_args, # type: "AxolotlTrainingArguments" # type: ignore
is_eval=False,
**kwargs,
):
if training_args.pretraining:
# Multimodal CPT: intercept BEFORE the text-only pretraining branches
# so our custom collator is wired up correctly.
# Training batches only — eval datasets from `test_datasets` are
# loaded through the regular path and don't carry the
# `_mm_text` / `images` columns MultiModalPretrainDataCollator
# requires, so an eval step would hard-fail in its torch_call.
if (
not is_eval
and self.cfg.processor_type
and self.processor
and _is_multimodal_cpt(self.cfg)
):
return self._build_mm_pretrain_collator(
pad_to_multiple_of=kwargs.get("pad_to_multiple_of"),
)
if (
self.cfg.pretraining_sample_concatenation is False
or self.cfg.micro_batch_size > 1
Expand Down Expand Up @@ -519,6 +582,15 @@ def build_collator(
else:
collator = BatchSamplerDataCollatorForSeq2Seq
else:
if (
not is_eval
and self.cfg.processor_type
and self.processor
and _is_multimodal_cpt(self.cfg)
):
return self._build_mm_pretrain_collator(
pad_to_multiple_of=kwargs.get("pad_to_multiple_of"),
)
if self.cfg.processor_type and self.processor:
collator = MultiModalChatDataCollator
kwargs["processing_strategy"] = get_processing_strategy(
Expand Down
Loading
Loading