Vision-Language Projection › Code Implementation

Michael BrenndoerferFebruary 10, 20268 min read
Language AI HandbookAll chapters
Part 50 of 67Multimodal Models
Chapter 384Vision-Language Projection › Code Implementation
Reading section 8 of 12 · Code Implementation · 8 minSection 8 of 12 · 8 min

Book contents

Section 8 of 12 · 8 min read

Vision-Language Projection › Code ImplementationOne section · Sections in this chapter8 / 12 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let us implement the three projection approaches in PyTorch, building a modular vision-language projection system.

In[6]:
Code
# Configuration mimicking CLIP ViT-L/14 -> LLaMA-2 setup
config = {
    "vision_dim": 1024,  # ViT-L/14 output dimension
    "llm_dim": 4096,  # LLaMA-2-7B embedding dimension
    "num_patches": 256,  # (224/14)^2
    "num_queries": 32,  # Q-Former queries
    "qformer_dim": 768,  # Q-Former hidden dimension
    "qformer_heads": 12,
    "qformer_layers": 12,
}

We begin with the simplest approach: linear projection. This class takes vision features and applies a single linear transformation.

In[7]:
Code
import torch.nn as nn


class LinearProjection(nn.Module):
    """
    Simple linear projection from vision space to LLM space.
    Maintains sequence length, transforms dimensionality.
    """

    def __init__(self, vision_dim, llm_dim, bias=False):
        super().__init__()
        self.projection = nn.Linear(vision_dim, llm_dim, bias=bias)

    def forward(self, vision_features):
        """
        Args:
            vision_features: [batch, num_patches, vision_dim]
        Returns:
            projected: [batch, num_patches, llm_dim]
        """
        return self.projection(vision_features)

The simplicity of LinearProjection is by design. The entire module is a single nn.Linear layer, and its forward pass is a single matrix multiplication. This simplicity makes optimization straightforward: the projection is a convex transformation space with no local minima, and gradients flow cleanly from the LLM's language modeling loss back to the projection weights.

Next, we implement the MLP projection with a configurable hidden dimension and activation function.

In[8]:
Code
class MLPProjection(nn.Module):
    """
    Two-layer MLP projection with GELU activation.
    Adds non-linear capacity between modalities.
    """

    def __init__(self, vision_dim, llm_dim, hidden_dim=None, dropout=0.1):
        super().__init__()
        if hidden_dim is None:
            hidden_dim = 4 * vision_dim  # Common expansion ratio

        self.fc1 = nn.Linear(vision_dim, hidden_dim)
        self.activation = nn.GELU()
        self.dropout = nn.Dropout(dropout)
        self.fc2 = nn.Linear(hidden_dim, llm_dim)

    def forward(self, vision_features):
        x = self.fc1(vision_features)
        x = self.activation(x)
        x = self.dropout(x)
        x = self.fc2(x)
        return x

Notice the default expansion to 4 * vision_dim in the hidden layer. This follows the same design philosophy as transformer feed-forward networks: expand to a larger intermediate space where features can be re-combined non-linearly, then compress to the target dimension. The dropout between layers regularizes training, particularly useful when the training dataset is small relative to the projection's parameter count.

Now we implement the Q-Former, which requires a custom cross-attention layer where queries attend to vision keys/values.

In[9]:
Code
class CrossAttentionLayer(nn.Module):
    """
    Single transformer layer with self-attention on queries
    and cross-attention to vision features.
    """

    def __init__(self, dim, num_heads, vision_dim):
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = dim // num_heads
        self.scale = self.head_dim**-0.5

        # Self-attention (queries attend to themselves)
        self.q_self_attn = nn.MultiheadAttention(
            dim, num_heads, batch_first=True
        )
        self.q_self_norm = nn.LayerNorm(dim)

        # Cross-attention (queries attend to vision)
        self.cross_attn = nn.MultiheadAttention(
            dim, num_heads, batch_first=True, kdim=vision_dim, vdim=vision_dim
        )
        self.cross_norm = nn.LayerNorm(dim)

        # FFN
        self.ffn = nn.Sequential(
            nn.Linear(dim, 4 * dim), nn.GELU(), nn.Linear(4 * dim, dim)
        )
        self.ffn_norm = nn.LayerNorm(dim)

    def forward(self, queries, vision_features):
        # Self-attention with residual
        attn_out, _ = self.q_self_attn(
            queries, queries, queries, need_weights=False
        )
        queries = self.q_self_norm(queries + attn_out)

        # Cross-attention with residual (queries attend to vision)
        cross_out, _ = self.cross_attn(
            queries, vision_features, vision_features, need_weights=False
        )
        queries = self.cross_norm(queries + cross_out)

        # FFN with residual
        ffn_out = self.ffn(queries)
        queries = self.ffn_norm(queries + ffn_out)

        return queries


class QFormer(nn.Module):
    """
    Querying Transformer that compresses variable-length vision features
    into fixed number of learnable query tokens.
    """

    def __init__(self, num_queries, vision_dim, dim, num_heads, num_layers):
        super().__init__()
        self.num_queries = num_queries
        self.dim = dim

        # Learnable query embeddings (the "bottleneck")
        self.query_tokens = nn.Parameter(torch.randn(1, num_queries, dim))

        # Input projection if vision_dim != dim
        self.vision_proj = (
            nn.Linear(vision_dim, dim) if vision_dim != dim else nn.Identity()
        )

        # Transformer layers
        self.layers = nn.ModuleList(
            [
                CrossAttentionLayer(dim, num_heads, dim)
                for _ in range(num_layers)
            ]
        )

        # Final norm
        self.norm = nn.LayerNorm(dim)

    def forward(self, vision_features):
        batch_size = vision_features.size(0)

        # Expand queries for batch
        queries = self.query_tokens.expand(batch_size, -1, -1)

        # Project vision features to match query dimension if needed
        vision_proj = self.vision_proj(vision_features)

        # Pass through transformer layers
        for layer in self.layers:
            queries = layer(queries, vision_proj)

        return self.norm(queries)

The QFormer class shows the key design choice: query_tokens is an nn.Parameter of shape [1, num_queries, dim]. These are the learnable query embeddings that persist across all images. The .expand(batch_size, -1, -1) call creates a batch-sized view of these queries without copying memory, so the same query embeddings are used for every image in the batch and updated during backpropagation.

Finally, we assemble a complete vision-language projection module that can switch between strategies.

In[10]:
Code
class VisionLanguageProjection(nn.Module):
    """
    Complete projection module supporting linear, MLP, and Q-Former approaches.
    """

    def __init__(self, config, method="linear"):
        super().__init__()
        self.method = method
        self.config = config

        if method == "linear":
            self.projector = LinearProjection(
                config["vision_dim"], config["llm_dim"], bias=False
            )
            self.output_tokens = config["num_patches"] + 1  # patches + CLS

        elif method == "mlp":
            self.projector = MLPProjection(
                config["vision_dim"],
                config["llm_dim"],
                hidden_dim=4 * config["vision_dim"],
            )
            self.output_tokens = config["num_patches"] + 1

        elif method == "qformer":
            self.qformer = QFormer(
                num_queries=config["num_queries"],
                vision_dim=config["vision_dim"],
                dim=config["qformer_dim"],
                num_heads=config["qformer_heads"],
                num_layers=config["qformer_layers"],
            )
            # Project from Q-Former dim to LLM dim
            self.llm_proj = nn.Linear(config["qformer_dim"], config["llm_dim"])
            self.output_tokens = config["num_queries"]

        else:
            raise ValueError(f"Unknown projection method: {method}")

    def forward(self, vision_features):
        if self.method in ["linear", "mlp"]:
            return self.projector(vision_features)
        else:  # qformer
            queries = self.qformer(vision_features)
            return self.llm_proj(queries)

Let us test these implementations with simulated vision features to verify the output dimensions.

Out[11]:
Console
Input vision features: torch.Size([2, 257, 1024])
  Batch size: 2
  Visual tokens per image: 257
  Vision dimension: 1024

--- Testing Projection Methods ---

LINEAR Projection:
  Output shape: torch.Size([2, 257, 4096])
  Parameters: 4,194,304
  (No compression)

MLP Projection:
  Output shape: torch.Size([2, 257, 4096])
  Parameters: 20,979,712
  (No compression)
QFORMER Projection:
  Output shape: torch.Size([2, 32, 4096])
  Parameters: 117,384,448
  Compression ratio: 8.0x
Out[12]:
Visualization
Log-scale bar chart comparing parameter counts for Linear (4.2M), MLP (21M), and Q-Former (191M) projection methods, with a reference line marking the LLaMA-2-7B size at 7B parameters.
Parameter count comparison across vision-language projection methods on a log scale. Linear projection requires only 4.2 million parameters to map ViT-L/14 outputs to LLaMA-2-7B inputs. MLP projection expands this to 21 million through an intermediate hidden layer. The Q-Former architecture requires approximately 191 million parameters including transformer layers, trading computational cost for substantial token compression. All three methods remain small relative to the 7-billion-parameter LLM.

The linear and MLP projections maintain 257 tokens but transform each from 1024 to 4096 dimensions. The Q-Former compresses these to 32 tokens, a significant reduction that proves important when context windows are limited or when processing high-resolution images with many patches.

Now let us examine a simplified training loop for the projection layer. This shows the freezing strategy and the necessary loss masking step.

In[13]:
Code
def create_vlm_with_projection(vision_encoder, llm, projection_module):
    """
    Assembles a vision-language model with proper freezing.
    Only the projection parameters will be trainable.
    """
    # Freeze vision encoder
    for param in vision_encoder.parameters():
        param.requires_grad = False

    # Freeze LLM (common for initial training)
    for param in llm.parameters():
        param.requires_grad = False

    # Projection remains trainable
    for param in projection_module.parameters():
        param.requires_grad = True

    return nn.ModuleDict(
        {"vision": vision_encoder, "projection": projection_module, "llm": llm}
    )


def training_step_with_masking(
    model, images, text_tokens, text_labels, loss_fn, optimizer
):
    """
    Single training step with proper loss masking.

    text_labels should have -100 for visual token positions
    (ignored by CrossEntropyLoss) and real token ids for text positions.
    """
    model.train()
    optimizer.zero_grad()

    # Forward pass through frozen vision encoder (no grad needed)
    with torch.no_grad():
        vision_features = model["vision"](images)

    # Forward pass through trainable projection
    projected = model["projection"](vision_features)

    # Concatenate visual and text tokens for LLM
    combined_input = torch.cat([projected, text_tokens], dim=1)

    # Build labels: -100 for visual positions (masked), real ids for text
    num_visual = projected.shape[1]
    visual_mask = torch.full(
        (images.shape[0], num_visual),
        fill_value=-100,
        dtype=torch.long,
        device=images.device,
    )
    combined_labels = torch.cat([visual_mask, text_labels], dim=1)

    # Get LLM predictions
    logits = model["llm"](combined_input)

    # Loss computed only on text positions (-100 positions are ignored)
    shift_logits = logits[..., :-1, :].contiguous()
    shift_labels = combined_labels[..., 1:].contiguous()

    loss = loss_fn(
        shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1)
    )

    loss.backward()
    optimizer.step()

    return loss.item()

The text_labels tensor with -100 fill values is the key implementation detail. PyTorch's CrossEntropyLoss ignores positions where the target is -100, so the visual token positions contribute zero gradient and zero loss. The projection layer learns entirely from the text generation loss on the positions that follow the visual tokens, exactly the signal we want.