blog

Hand-Writing a Vision-Language Model, Part 10: The Model Ignoring the Image, and Diagnosing Why

EOS Fix Confirmed

Retrained with EOS-appended labels, then tested generation across four maze seeds: two from the training set (0 and 5), two held out (600 and 601). Stopping now works in all four cases. Generation terminates with <|im_end|> instead of rambling past the answer.

Then looked at the actual predicted points.

The Real Problem

seed   0 (in-training): (249,83) (249,249) (416,249) (416,416) (582,416) (582,582) (749,582) (749,749) (915,749) (915,915)
seed   5 (in-training): (249,83) (249,249) (416,249) (416,416) (582,416) (582,582) (749,582) (749,749) (915,749) (915,915)
seed 600 (held-out):    (249,83) (249,249) (416,249) (416,416) (582,416) (582,582) (749,582) (749,749) (915,749) (915,915)
seed 601 (held-out):    (similar zigzag shape, slightly longer)

Seeds 0, 5, and 600 produced the exact identical point sequence. Three different mazes, one memorised generic answer. Not "close but imperfect navigation." The model is not looking at the image at all. It is reproducing "the general shape maze solutions tend to have in this training set" as a pure language pattern, completely independent of what maze is actually shown.

Diagnosing Instead of Guessing

The obvious next move would have been "train longer" or "increase LoRA capacity." But neither is well-motivated without knowing where the image's distinguishing information actually gets lost. So instead: measure it directly, at each stage of the pipeline, on the same set of maze images.

patch_embeds = vlm.vision_encoder(pixel_values)   # raw, frozen ViT output
vision_embeds = vlm.projector(patch_embeds)        # after the trained projector

Pairwise cosine similarity between five different maze images, at each stage:

Stage Different-maze similarity
Raw ViT patch_embeds~0.48 to 0.58
Projected vision_embeds~0.80 to 0.84

(Confirmed first that the input images themselves genuinely differ: pixel-level mean absolute difference 0.05 to 0.12 across seeds, so this is not an input bug.)

The frozen ViT differentiates mazes reasonably well: similarity well below 1.0. The projector then compresses much of that differentiation away, pushing already-different images into embeddings that look substantially more alike than what it was given.

A plausible explanation: the projector was trained in stage 1 entirely on natural Flickr8k photographs. People, animals, scenes. Maze line-drawings, flat white backgrounds, thin black lines, no texture or colour, are wildly out-of-distribution for whatever the projector learned to key off of. It may be folding many different synthetic diagrams into a fairly generic region of Qwen's embedding space, simply because it never learned what distinguishes one maze from another.

Combined with LoRA rank 8 (not much room for Qwen's attention to amplify whatever weak distinguishing signal does survive the projector), the two effects compound into the fully collapsed, image-independent output observed.

LoRA Rank: The Autoencoder Framing

Talking through why increasing rank might help surfaced a cleaner way to describe what rank actually is.

LoRALinear is structurally an autoencoder bottleneck sitting next to the frozen layer. lora_A compresses the input down to rank numbers (the encoder). lora_B expands those numbers back out to the real output size (the decoder). Rank is the width of that narrow waist in the middle: not a target size to "expand to," but the number of independent directions of adjustment allowed through the pinch point before being blown back up to full size.

No matter how the expansion step is done, it can never encode more independent patterns of variation than the waist allowed through. That is the whole mechanism behind why rank caps correction complexity. Rank 8 means the LoRA adapter can only ever express adjustments that live in an 8-dimensional subspace, regardless of the size of the matrices on either side.

Decision: Increase Rank First, Retrain, Re-Check

Bumped LORA_RANK from 8 to 32, a 4x increase, still tiny relative to full fine-tuning, and retrained.

This targets the second half of the compounding problem (giving attention more room to use whatever signal exists) without yet touching the projector side, which the diagnostic points at as the more likely root cause. Worth checking whether more attention capacity alone moves the needle before deciding whether the projector itself needs more or different training.

The sequencing is deliberate: change one variable, measure the result, then decide what to change next. Not two changes at once.

What Is Next

Once the rank-32 run finishes: re-check GPU memory (rank 32 quadruples LoRA's parameter count, though it remains small overall), then re-run the same four-seed generation test.

If the model now differentiates mazes: the rank was the bottleneck, and the projector's signal survived after all. If it still produces the same generic path: the projector's signal-narrowing is the harder bottleneck and needs addressing directly, with more projector training, maze-specific alignment data in stage 1, or both. That result will determine the next move cleanly.

part 9
Hand-Writing a Vision-Language Model, Part 9: Testing What Stage 2 Actually Learned
part 8
Hand-Writing a Vision-Language Model, Part 8: Hand-Writing LoRA, and a Real Memory Bug That Was Not What It Looked Like
part 7
Hand-Writing a Vision-Language Model, Part 7: Stage 2 Kickoff, Mazes, Coordinates, and a Reserved Vocab Surprise
related read
Going Below PyTorch: Learning Raw CUDA Kernel Development
Python PyTorch LoRA HuggingFace Transformers GTX 1080 Pascal CC 6.1 CUDA Toolkit 12.5 Ubuntu 24.04