Lesson 49's CLIP matched a fixed set of text prompts against an image. Visual Question Answering (VQA) asks for something more flexible: given an image and an arbitrary natural-language question about it, produce an answer. This is the mechanism behind modern multimodal assistants that can look at a picture and answer questions about it — a generative-VLM capability, distinct from CLIP's contrastive matching. This lesson builds the classic (and still widely used) simplification: treat VQA as classification over a small, closed vocabulary of possible answers, fusing an image encoder and a question encoder before the final prediction.
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import matplotlib.pyplot as plt
Scenes contain 0-3 plus/circle shapes. Each scene gets one of four question templates: "what shape is in the image" (only meaningful when exactly one shape is present), "how many shapes are there", "is there a circle in the image", "is there a plus in the image" — with the true answer computed directly from the scene's ground truth. The answer space is a small, fixed vocabulary: shape names, counts, and yes/no.
SIZE = 32
QUESTION_VOCAB = ['<pad>', 'what', 'shape', 'is', 'this', 'how', 'many', 'shapes', 'are', 'there',
'a', 'circle', 'plus', 'in', 'the', 'image']
Q2ID = {w: i for i, w in enumerate(QUESTION_VOCAB)}
ANSWER_VOCAB = ['plus', 'circle', '0', '1', '2', '3', 'yes', 'no']
A2ID = {w: i for i, w in enumerate(ANSWER_VOCAB)}
def make_shape(img, shape_type, cx, cy, r=4):
yy, xx = np.mgrid[0:img.shape[0], 0:img.shape[1]]
if shape_type == 'plus':
img[cy-1:cy+2, cx-r:cx+r+1] = 1.0
img[cy-r:cy+r+1, cx-1:cx+2] = 1.0
else:
img[((xx - cx) ** 2 + (yy - cy) ** 2) <= r ** 2] = 1.0
return img
def make_scene(rng, size=SIZE, max_shapes=3):
n = rng.integers(0, max_shapes + 1)
img = np.zeros((size, size), dtype=np.float32)
shapes = []
tries = 0
while len(shapes) < n and tries < 30:
tries += 1
cx, cy = rng.integers(6, size - 6), rng.integers(6, size - 6)
if any(abs(cx - sx) < 9 and abs(cy - sy) < 9 for sx, sy, _ in shapes):
continue
st = rng.choice(['plus', 'circle'])
make_shape(img, st, cx, cy)
shapes.append((cx, cy, st))
img = np.clip(img + rng.normal(0, 0.03, img.shape), 0, 1).astype(np.float32)
return img, shapes
def make_qa(rng, shapes):
n_circle = sum(1 for _, _, t in shapes if t == 'circle')
n_plus = sum(1 for _, _, t in shapes if t == 'plus')
qtype = rng.choice(['what_shape', 'count', 'is_there_circle', 'is_there_plus'])
if qtype == 'what_shape':
words = ['what', 'shape', 'is', 'in', 'the', 'image']
answer = shapes[0][2] if len(shapes) == 1 else str(min(len(shapes), 3))
elif qtype == 'count':
words = ['how', 'many', 'shapes', 'are', 'there', 'in', 'the', 'image']
answer = str(len(shapes))
elif qtype == 'is_there_circle':
words = ['is', 'there', 'a', 'circle', 'in', 'the', 'image']
answer = 'yes' if n_circle > 0 else 'no'
else:
words = ['is', 'there', 'a', 'plus', 'in', 'the', 'image']
answer = 'yes' if n_plus > 0 else 'no'
return words, [Q2ID[w] for w in words], A2ID[answer]
def pad_question(ids, max_len=8):
return ids + [0] * (max_len - len(ids)) # 0 = <pad>, distinct from every real word
rng = np.random.default_rng(4)
N = 1000
imgs, questions, answers, question_words = [], [], [], []
for _ in range(N):
img, shapes = make_scene(rng)
words, q_ids, a_id = make_qa(rng, shapes)
imgs.append(img)
questions.append(pad_question(q_ids))
answers.append(a_id)
question_words.append(words)
imgs = np.array(imgs, dtype=np.float32)
questions = np.array(questions, dtype=np.int64)
answers = np.array(answers, dtype=np.int64)
split = int(0.85 * N)
Xtr, Qtr, Atr = imgs[:split], questions[:split], answers[:split]
Xte, Qte, Ate = imgs[split:], questions[split:], answers[split:]
fig, axes = plt.subplots(1, 4, figsize=(9, 2.5))
for ax, im, words, a in zip(axes, imgs[:4], question_words[:4], answers[:4]):
ax.imshow(im, cmap='gray'); ax.axis('off')
ax.set_title(f'{" ".join(words)}?\n-> {ANSWER_VOCAB[a]}', fontsize=7)
plt.show()
Both encoders map to the same-size vector, same pattern as Lesson 49's CLIP. The difference is what happens next: CLIP compared image and text embeddings with a dot product to check if they match. VQA needs to actually combine them into one joint representation and classify — a standard, simple fusion is elementwise multiplication (the image vector and the question vector gate each other), followed by a small classifier head over the fixed answer vocabulary.
class ImageEncoder(nn.Module):
def __init__(self, out_dim=32):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(1, 16, 5, padding=2), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 5, padding=2), nn.ReLU(), nn.AdaptiveMaxPool2d(1))
self.proj = nn.Linear(32, out_dim)
def forward(self, x):
return self.proj(self.conv(x).flatten(1))
class QuestionEncoder(nn.Module):
def __init__(self, vocab_size, out_dim=32, embed_dim=16):
super().__init__()
self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.proj = nn.Linear(embed_dim, out_dim)
def forward(self, ids):
mask = (ids != 0).float().unsqueeze(-1) # ignore <pad> tokens when pooling
emb = self.embed(ids) * mask
pooled = emb.sum(1) / mask.sum(1).clamp(min=1)
return self.proj(pooled)
class VQAModel(nn.Module):
def __init__(self, vocab_size, n_answers, dim=32):
super().__init__()
self.img_enc = ImageEncoder(dim)
self.q_enc = QuestionEncoder(vocab_size, dim)
self.head = nn.Sequential(nn.Linear(dim, 32), nn.ReLU(), nn.Linear(32, n_answers))
def forward(self, img, q_ids):
fused = self.img_enc(img) * self.q_enc(q_ids)
return self.head(fused)
torch.manual_seed(0)
model = VQAModel(len(QUESTION_VOCAB), len(ANSWER_VOCAB))
opt = torch.optim.Adam(model.parameters(), lr=0.01)
Xt = torch.tensor(Xtr).unsqueeze(1); Qt = torch.tensor(Qtr); At = torch.tensor(Atr)
for _ in range(300):
opt.zero_grad()
loss = F.cross_entropy(model(Xt, Qt), At)
loss.backward()
opt.step()
with torch.no_grad():
preds = model(torch.tensor(Xte).unsqueeze(1), torch.tensor(Qte)).argmax(1).numpy()
acc = (preds == Ate).mean()
most_common = np.bincount(Atr).argmax()
baseline_acc = (Ate == most_common).mean()
print(f'VQA test accuracy: {acc:.1%}')
print(f'baseline (always predict the most common training answer, "{ANSWER_VOCAB[most_common]}"): {baseline_acc:.1%}')
A model that's secretly ignoring the question and just pattern-matching the image would give the same answer regardless of what's asked. Pose all four question types against one fixed test image and check.
test_rng = np.random.default_rng(123)
demo_img, demo_shapes = make_scene(test_rng)
while len(demo_shapes) != 2 or demo_shapes[0][2] == demo_shapes[1][2]:
demo_img, demo_shapes = make_scene(test_rng) # find a scene with one of each shape
demo_questions = [
['how', 'many', 'shapes', 'are', 'there', 'in', 'the', 'image'],
['is', 'there', 'a', 'circle', 'in', 'the', 'image'],
['is', 'there', 'a', 'plus', 'in', 'the', 'image'],
]
img_t = torch.tensor(demo_img[None, None])
print(f'true scene contents: {[s[2] for s in demo_shapes]}')
with torch.no_grad():
for words in demo_questions:
q_ids = torch.tensor([pad_question([Q2ID[w] for w in words])])
pred = model(img_t, q_ids).argmax(1).item()
print(f' "{" ".join(words)}?" -> {ANSWER_VOCAB[pred]}')
The same fixed image produces three different, individually correct answers depending on what's asked — the fusion step is genuinely reading both inputs, not just memorizing image-to-answer shortcuts.
Treating VQA as classification over a small fixed answer set (as this lesson did) was the dominant approach for years and is still used when the answer space genuinely is small and known in advance. It breaks down the moment an answer needs to be open-ended text — "describe what's unusual about this image," or an answer word that never appeared in training. Modern VQA and image-captioning systems solve this the way Lesson 45's decoder does: replace the classification head with an autoregressive text decoder (causal self-attention over previously generated words, cross-attention into the image features) that generates the answer one token at a time, exactly the mechanism behind large multimodal assistants that can describe, question, and reason about an image in free-form language rather than picking from a fixed list.
cy < SIZE // 2). Retrain and check whether accuracy on this new question type matches the others — does the image encoder's global max pooling (Lesson 33) make position-based questions structurally harder to answer than presence/count questions?img_enc(img) * q_enc(q_ids)) with concatenation followed by a linear layer (nn.Linear(2 * dim, dim) on torch.cat([img_feat, q_feat], dim=-1)). Does accuracy change noticeably, and which fusion method converges faster during training?"is there a circle" and "there a circle is" encode identically (Lesson 49's exercise made the same point about captions). Construct a question pair for this dataset where that word-order-blindness would actually cause an answer error, if such a pair exists — or explain why this dataset's question templates are simple enough that it never matters.