An Image Is Worth 16×16 Words
The whole paper is one reshape. I kept looking for the clever part, and it was .reshape the entire time.
Download vit_from_scratch.py — everything below runs from it. NumPy only.
I'd shipped image classifiers: fine-tuned ResNets, exported to ONNX, argued about input normalisation in code review. Then transformers ate vision and I assumed there was a new trick for pixels. There isn't. Cut the image into tiles, flatten each into a row, hand the rows to the exact encoder from the attention page. The interesting engineering is what that reshape throws away, and why it wins anyway.
Tokenising an image
You know this pipeline. Every language model starts with it:
ids = tokenizer("the cat ate food") # [464, 3797, 8268, 2057]
tokens = embedding_table[ids] # (4, D) one row per word
out = transformer(tokens) # (4, D) same shape, now in context
The paper is that pipeline with a different tokeniser. Words come from a vocabulary; tiles come from a grid:
patches = patchify(image, p=16) # (196, 768) 196 tiles, 16*16*3 each
tokens = patches @ E # (196, D) a matmul, not a lookup
tokens = concat([cls, tokens]) + pos # (197, D) one extra row, plus order
out = transformer(tokens) # (197, D) unchanged since 2017
Two differences from text, and they're the only image-specific ideas in the paper. No vocabulary: a tile is 768 continuous numbers, not an id, so the embedding is a matmul, not a lookup. And the tokeniser has no rules. It's a grid.
| Term | Means | Programmer's analogy |
|---|---|---|
| Patch | One p×p×3 tile of the image | A word |
| Flatten | That tile as one row of p·p·3 numbers | The word's bytes |
| E | A (p·p·3, D) matrix every row is multiplied by | The embedding table, as a matmul because there's no vocab |
| CLS token | One learned row prepended to the sequence | A sentinel. The row you read at the end |
| Position embedding | A learned (N+1, D) table added to the rows | The index in the list. Without it the list is a set |
Why it beat the CNN — and when it didn't
A convolution is a for loop over a sliding window:
for y, x in every_pixel:
out[y, x] = kernel · img[y-1:y+2, x-1:x+2] # only the 3x3 neighbourhood,
# same kernel at every (y, x)
Two assumptions are baked into that loop: nearby pixels matter more than far ones, and a cat in the corner is the same cat as one in the middle. A CNN gets both for free from its structure. ViT gets neither: tile 0 and tile 195 are related only through attention weights it must learn, and "nearby pixels matter" is something it must discover from data. The paper's phrase is much less image-specific inductive bias. Fewer assumptions is a trade: worse when data is scarce, better once there's enough data to learn the assumptions and then some. On ImageNet-1k alone, ViT lands below a ResNet of comparable size. Pretrained on ImageNet-21k (14M images) or JFT-300M it wins; the biggest, ViT-H/14, tops the paper's headline table at 88.55% on ImageNet. The interview question is "why does ViT need more data than a CNN?", and the answer is that sentence about inductive bias.
The one formula
Equation 1. Read it left to right as shapes, for ViT-Base on a 224×224 image with patch 16:
| Step | Shape | What it does |
|---|---|---|
x | (224, 224, 3) | The image. Height, width, channel |
xp | (196, 768) | The reshape. N = 224·224 / 16² = 196 tiles, each 16·16·3 = 768 numbers |
E | (768, 768) | Patch embedding. Square only because ViT-Base picked D = 768 |
xpE | (196, 768) | 196 tokens. From here on, this is a sentence |
xclass | (1, 768) | The learned CLS row, prepended → (197, 768) |
+ Epos | (197, 768) | Learned, 1D, one vector per slot. Added, not concatenated |
Equations 2 and 3 are the encoder block, stacked 12 deep for Base, 24 for Large, 32 for Huge; Equation 4 says read row 0 after a final LayerNorm. Both are on the attention page already. The one change since 2017: LayerNorm moves to before each sublayer (pre-norm), and the MLP uses GELU. At fine-tune time the paper feeds a bigger image, keeps the patch size so there are more tokens, and 2D-interpolates the learned position table to the new grid; they note that's the only place a 2D assumption goes in by hand.
The reshape, with every shape printed
The script uses a 32×32×3 toy image and 8×8 patches so the numbers stay readable: 16 tiles of 192 numbers. Three steps. Cut each spatial axis into (how many tiles, pixels per tile). Group the two "which tile" axes together and the two "which pixel" axes together, which is a transpose. Flatten to one row per tile. Demo 1 does it one line at a time so you can breakpoint each step.
Don't take my word for it. Here's the actual output:
image (32, 32, 3) (H, W, C)
cut (4, 8, 4, 8, 3) (H/p, p, W/p, p, C)
grouped (4, 4, 8, 8, 3) (H/p, W/p, p, p, C)
patches (16, 192) (N, p*p*C) <- the sentence
tokens (16, 64) (N, D) patches @ E
+ cls (17, 64) (N+1, D) BERT-style
+ pos (17, 64) (N+1, D) added
block (17, 64) (N+1, D) shape unchanged
attn (17, 17) (N+1, N+1) all patches see all
cls out (64,) (D,) the row we read
logits (10,) (n_classes,)
unpatchify(patchify(img)) == img? True
3072 numbers in, 3072 out, regrouped into 16 rows. Nothing is computed
until patches @ E. From (16, 64) onward the shapes
are the attention page's (n_tokens, d_model), and the 17×17
attention matrix means every tile sees every tile, plus CLS. The round-trip
at the bottom is the test I'd write: if it fails, the reshape is wrong,
whatever the shapes say.
Position: a bag of tiles
Attention is a set operation. The attention page proved it with "cat ate food" versus "food ate cat"; here it's a jigsaw with the pieces swapped. Shuffle the 16 tiles into a new image, run both, and compare the CLS row with the position table off and on:
tile permutation: [ 1 12 7 10 14 4 5 8 0 9 2 13 11 6 3 15]
shuffled image == original? False
position embeddings OFF: CLS max abs diff = 1.44e-15 allclose? True ← blind
position embeddings ON : CLS max abs diff = 0.0495 allclose? False ← sees it
That 1.44e-15 is not zero, and I won't pretend it is. The shuffle changes
the order the attention sum runs in, and float64 rounds differently in a
different order. Fifteen decimal places is "identical" by any test you'd
write; the 0.0495 with positions on is thirteen orders of magnitude bigger.
That + self.pos is the only thing telling the model where a
tile was. The paper keeps the table deliberately dumb: learned, 1D, one
vector per slot, like a list index. They tried 2D-aware versions and report
no gain, because the plain table learns the grid on its own; the paper's
inspection section has the picture, where the similarity between learned
position vectors forms rows and columns.
The same shuffle settles which row you read at the end. BERT-style it's row 0, but there are three obvious candidates:
max abs diff under the shuffle, position embeddings OFF:
z[0] CLS token 1.44e-15
z[1:].mean mean-pool 6.66e-16
z[6] patch token 6 1.47
(token 6 now holds tile 4, not tile 5)
CLS and the mean of the patch rows are both reads over the whole set, so both survive. A single patch row is a read at a position, so it moves. The paper's appendix tried mean-pooling instead of CLS and found it works just as well once the learning rate is re-tuned; the CLS token is a BERT habit, not a requirement.
Why 16×16: the n² bill
Patch size is the one dial the paper adds, and it sets everything. Tokens n = (224/p)², and every attention layer builds an n×n score matrix per head. The script prints the bill for a 224×224 image:
patch per token tokens n n x n vs 16x16
1x1 3 50176 2,517,630,976 65536.00x
4x4 48 3136 9,834,496 256.00x
8x8 192 784 614,656 16.00x
16x16 768 196 38,416 1.00x
32x32 3072 49 2,401 0.06x
Halve the patch, quadruple the tokens, sixteen times the attention matrix. One pixel per token would be 2.5 billion scores per head per layer, which is why nobody does that. 16×16 gives 196 tokens: a paragraph, not a book. Two things fall out of the table. 16·16·3 = 768 is exactly ViT-Base's D, so its embedding matrix happens to be square. And the model names: ViT-L/16 is Large with patch 16; ViT-H/14 uses patch 14 for more tokens at more cost.
The wrong reshape is silent
This is the bug you will actually ship. Skip the cut and the transpose and
go straight to img.reshape(16, 192). NumPy reads the image row
by row, so each "patch" is two full-width strips, not an 8×8 square. The
shape is identical. Nothing throws.
shapes: (16, 192) (16, 192) equal? True
patch 5, first 8 values (R G B R G B R G):
right: [0.258 0.258 0. 0.29 0.258 0. 0.323 0.258]
wrong: [0. 0.323 1. 0.032 0.323 1. 0.065 0.323]
what is 'patch 5', really?
right == img[8:16, 8:16] an 8x8 square? True
wrong == img[10:12, :] a 2x32 strip? True
model runs on the wrong patches anyway: logits (10,)
Red ramps left to right in the toy image, green top to bottom. The right row starts at pixel (8, 8), so red and green both read 0.258. The wrong row starts at pixel (10, 0): red 0, green 0.323, blue on the wrong checkerboard square. Same shape, different picture, ten plausible logits. It will even train; the accuracy is just quietly bad and you'll blame the learning rate. The PyTorch flavour is running this recipe on a (C, H, W) tensor without moving the channel axis first. The failure you want is the loud one: 32 isn't divisible by 5, the cut can't happen, and NumPy refuses. Real pipelines resize to 224 before patchifying for exactly this reason.
p=8: patches (16, 192)
p=5: ValueError: cannot reshape array of size 3072 into shape (6,5,6,5,3)
நான் image classifiers ship செய்திருக்கிறேன்: ResNet-களை fine-tune செய்து, ONNX-ஆக export செய்து, code review-ல் input normalisation பற்றி வாதாடியிருக்கிறேன். பிறகு transformers vision-ஐ விழுங்கின, pixel-களுக்கு ஏதோ ஒரு புதிய தந்திரம் இருக்கும் என்று நினைத்தேன். இல்லை. படத்தை tile-களாக வெட்டுங்கள், ஒவ்வொன்றையும் ஒரு row-ஆக flatten செய்யுங்கள், அந்த row-களை attention page-ன் அதே encoder-இடம் கொடுங்கள். சுவாரஸ்யமான engineering என்னவென்றால் — அந்த reshape எதைத் தூக்கி எறிகிறது, இருந்தும் ஏன் ஜெயிக்கிறது.
ஒரு படத்தை tokenise செய்வது
இந்த pipeline உங்களுக்குத் தெரியும். ஒவ்வொரு language model-ம் இதில்தான் தொடங்குகிறது:
ids = tokenizer("the cat ate food") # [464, 3797, 8268, 2057]
tokens = embedding_table[ids] # (4, D) ஒரு word = ஒரு row
out = transformer(tokens) # (4, D) அதே shape, context-உடன்
இந்த paper அதே pipeline-தான், tokeniser மட்டும் வேறு. Word-கள் ஒரு vocabulary-லிருந்து வருகின்றன; tile-கள் ஒரு grid-லிருந்து வருகின்றன:
patches = patchify(image, p=16) # (196, 768) 196 tiles, 16*16*3
tokens = patches @ E # (196, D) matmul, lookup அல்ல
tokens = concat([cls, tokens]) + pos # (197, D) +1 row, +வரிசை
out = transformer(tokens) # (197, D) 2017-லிருந்து மாற்றமில்லை
Text-லிருந்து இரண்டே வித்தியாசங்கள், அவைதான் இந்த paper-ல் உள்ள ஒரே image-specific கருத்துகள். Vocabulary இல்லை: ஒரு tile என்பது 768 தொடர்ச்சியான எண்கள், ஒரு id அல்ல — எனவே embedding என்பது ஒரு matmul, lookup அல்ல. மேலும் இந்த tokeniser-க்கு விதிகளே இல்லை. அது ஒரு grid, அவ்வளவுதான்.
| சொல் | அர்த்தம் | Programmer-க்கான உதாரணம் |
|---|---|---|
| Patch | படத்தின் ஒரு p×p×3 tile | ஒரு word |
| Flatten | அந்த tile-ஐ p·p·3 எண்கள் கொண்ட ஒரே row-ஆக்குவது | அந்த word-ன் bytes |
| E | ஒவ்வொரு row-ம் இதனால் பெருக்கப்படும் (p·p·3, D) matrix | Embedding table — vocab இல்லாததால் lookup-க்குப் பதில் matmul |
| CLS token | Sequence-ன் முன்னால் சேர்க்கப்படும் ஒரு கற்ற row | ஒரு sentinel. கடைசியில் நீங்கள் படிக்கும் row |
| Position embedding | Row-களுடன் கூட்டப்படும் ஒரு கற்ற (N+1, D) table | List-ல் உள்ள index. இது இல்லாவிட்டால் list ஒரு set |
CNN-ஐ ஏன் இது வென்றது — எப்போது வெல்லவில்லை
ஒரு convolution என்பது sliding window மேல் ஓடும் ஒரு for loop:
for y, x in every_pixel:
out[y, x] = kernel · img[y-1:y+2, x-1:x+2] # 3x3 அண்டை பகுதி மட்டும்,
# எல்லா (y, x)-லும் அதே kernel
அந்த loop-க்குள் இரண்டு அனுமானங்கள் சுடப்பட்டிருக்கின்றன: அருகில் உள்ள pixel-கள் தூரத்தில் உள்ளவற்றை விட முக்கியம், மேலும் மூலையில் உள்ள பூனையும் நடுவில் உள்ள பூனையும் ஒன்றுதான். CNN-க்கு இவை இரண்டும் அதன் அமைப்பிலிருந்தே இலவசமாகக் கிடைக்கின்றன. ViT-க்கு இரண்டுமே இல்லை: tile 0-ம் tile 195-ம் attention weights மூலம் மட்டுமே தொடர்பு கொள்கின்றன, அதையும் அது கற்க வேண்டும்; "அருகில் உள்ள pixel-கள் முக்கியம்" என்பதையும் data-விலிருந்து கண்டுபிடிக்க வேண்டும். Paper-ன் சொற்றொடர்: much less image-specific inductive bias. குறைவான அனுமானங்கள் என்பது ஒரு பேரம்: data குறைவாக இருக்கும்போது மோசம், ஆனால் அந்த அனுமானங்களையே கற்கும் அளவு data இருந்தால் அதற்கும் மேல். ImageNet-1k மட்டும் என்றால் ViT, அதே அளவு ResNet-க்குக் கீழேயே இருக்கிறது. ImageNet-21k (14M படங்கள்) அல்லது JFT-300M-ல் pretrain செய்தால் அது ஜெயிக்கிறது; மிகப்பெரிய ViT-H/14, paper-ன் தலைப்பு table-ல் ImageNet-ல் 88.55% எனத் தலைமை வகிக்கிறது. Interview கேள்வி "CNN-ஐ விட ViT-க்கு ஏன் அதிக data தேவை?" — பதில் அந்த inductive bias வாக்கியம்தான்.
அந்த ஒரு சூத்திரம்
Equation 1. இதை இடமிருந்து வலமாக shape-களாகப் படியுங்கள் — 224×224 படத்தில், patch 16, ViT-Base-க்கு:
| படி | Shape | என்ன செய்கிறது |
|---|---|---|
x | (224, 224, 3) | படம். Height, width, channel |
xp | (196, 768) | அந்த reshape. N = 224·224 / 16² = 196 tiles, ஒவ்வொன்றும் 16·16·3 = 768 எண்கள் |
E | (768, 768) | Patch embedding. ViT-Base, D = 768 எடுத்ததால் மட்டுமே இது சதுரம் |
xpE | (196, 768) | 196 tokens. இங்கிருந்து இது ஒரு வாக்கியம் |
xclass | (1, 768) | கற்ற CLS row, முன்னால் சேர்க்கப்படுகிறது → (197, 768) |
+ Epos | (197, 768) | கற்றது, 1D, ஒரு slot-க்கு ஒரு vector. கூட்டப்படுகிறது, concat அல்ல |
Equations 2, 3 என்பவை encoder block — Base-க்கு 12 அடுக்கு, Large-க்கு 24, Huge-க்கு 32; Equation 4 சொல்வது, கடைசி LayerNorm-க்குப் பிறகு row 0-ஐப் படியுங்கள். இரண்டுமே attention page-ல் ஏற்கனவே இருக்கின்றன. 2017-லிருந்து ஒரே மாற்றம்: LayerNorm ஒவ்வொரு sublayer-க்கும் முன் நகர்கிறது (pre-norm), MLP GELU-ஐப் பயன்படுத்துகிறது. Fine-tune நேரத்தில் paper பெரிய படத்தைக் கொடுக்கிறது, patch size-ஐ அப்படியே வைத்திருப்பதால் tokens அதிகமாகின்றன, கற்ற position table-ஐ புதிய grid-க்கு 2D-interpolate செய்கிறது; கையால் ஒரு 2D அனுமானம் நுழையும் ஒரே இடம் அதுதான் என்று அவர்களே குறிப்பிடுகிறார்கள்.
அந்த reshape, ஒவ்வொரு shape-ஐயும் அச்சிட்டு
Script ஒரு 32×32×3 பொம்மைப் படத்தையும் 8×8 patch-களையும் பயன்படுத்துகிறது, எண்கள் படிக்கக்கூடியதாக இருக்க: 192 எண்கள் கொண்ட 16 tiles. மூன்று படிகள். Cut — ஒவ்வொரு spatial axis-ஐயும் (எத்தனை tiles, ஒரு tile-ல் எத்தனை pixels) என்று பிரியுங்கள். Group — "எந்த tile" என்ற இரு axis-களை ஒன்றாகவும், "எந்த pixel" என்ற இரு axis-களை ஒன்றாகவும் கொண்டு வாருங்கள்; இது ஒரு transpose. Flatten — ஒரு tile-க்கு ஒரு row. Demo 1 இதை ஒவ்வொரு வரியாகச் செய்கிறது, நீங்கள் ஒவ்வொரு படியிலும் breakpoint வைக்கலாம்.
என் வார்த்தையை நம்ப வேண்டாம். இதோ உண்மையான output:
image (32, 32, 3) (H, W, C)
cut (4, 8, 4, 8, 3) (H/p, p, W/p, p, C)
grouped (4, 4, 8, 8, 3) (H/p, W/p, p, p, C)
patches (16, 192) (N, p*p*C) <- the sentence
tokens (16, 64) (N, D) patches @ E
+ cls (17, 64) (N+1, D) BERT-style
+ pos (17, 64) (N+1, D) added
block (17, 64) (N+1, D) shape unchanged
attn (17, 17) (N+1, N+1) all patches see all
cls out (64,) (D,) the row we read
logits (10,) (n_classes,)
unpatchify(patchify(img)) == img? True
3072 எண்கள் உள்ளே, 3072 வெளியே, 16 row-களாக மறு-குழுவாக்கப்பட்டு.
patches @ E வரை எதுவும் கணக்கிடப்படவில்லை. (16, 64)
முதல் shape-கள் attention page-ன் (n_tokens, d_model)-தான், மேலும்
17×17 attention matrix அர்த்தம் — ஒவ்வொரு tile-ம் ஒவ்வொரு tile-ஐயும் பார்க்கிறது,
கூட CLS-ஐயும். கீழே உள்ள round-trip நான் எழுதும் test: அது தோற்றால், shape-கள்
என்ன சொன்னாலும் reshape தவறு.
Moonu padi: cut, group, flatten. Reshape-um transpose-um dhaan, kanakku illa. Round-trip test fail aanaa, shapes correct-aa irundhaalum reshape thappu.
Position: tile-களின் ஒரு மூட்டை
Attention ஒரு set operation. Attention page அதை "cat ate food" எதிராக "food ate cat" வைத்து நிரூபித்தது; இங்கே அது துண்டுகள் மாற்றப்பட்ட ஒரு jigsaw. 16 tile-களைக் கலைத்து ஒரு புதிய படம் ஆக்குங்கள், இரண்டையும் ஓட்டி, position table-ஐ off செய்தும் on செய்தும் CLS row-ஐ ஒப்பிடுங்கள்:
tile permutation: [ 1 12 7 10 14 4 5 8 0 9 2 13 11 6 3 15]
shuffled image == original? False
position embeddings OFF: CLS max abs diff = 1.44e-15 allclose? True ← குருடு
position embeddings ON : CLS max abs diff = 0.0495 allclose? False ← தெரியும்
அந்த 1.44e-15 பூஜ்யம் அல்ல, அதை பூஜ்யம் என்று நான் நடிக்கப் போவதில்லை.
Shuffle செய்வது attention கூட்டல் நடக்கும் வரிசையை மாற்றுகிறது, வேறு வரிசையில்
float64 வேறு விதமாக round செய்கிறது. நீங்கள் எழுதும் எந்த test-க்கும் பதினைந்து
தசம இடங்கள் என்பது "ஒன்றுதான்"; position-ஐ on செய்தால் வரும் 0.0495 அதைவிட
பதிமூன்று மடங்கு அளவுகள் பெரியது. அந்த + self.pos-தான் ஒரு tile
எங்கே இருந்தது என்பதை model-க்குச் சொல்லும் ஒரே விஷயம். Paper அந்த table-ஐ
வேண்டுமென்றே மக்காக வைத்திருக்கிறது: கற்றது, 1D, ஒரு slot-க்கு ஒரு vector,
list index போல. 2D-aware வடிவங்களை முயற்சித்து எந்த லாபமும் இல்லை என்று
சொல்கிறார்கள் — ஏனெனில் சாதாரண table-ஏ grid-ஐத் தானாகக் கற்றுக்கொள்கிறது;
paper-ன் inspection பகுதியில் அந்தப் படம் இருக்கிறது, அங்கே கற்ற position
vector-களுக்கு இடையேயான ஒற்றுமை row-களாகவும் column-களாகவும் உருவாகிறது.
அதே shuffle, கடைசியில் எந்த row-ஐப் படிப்பது என்பதையும் தீர்த்து வைக்கிறது. BERT-style என்றால் row 0, ஆனால் மூன்று வெளிப்படையான தேர்வுகள் இருக்கின்றன:
max abs diff under the shuffle, position embeddings OFF:
z[0] CLS token 1.44e-15
z[1:].mean mean-pool 6.66e-16
z[6] patch token 6 1.47
(token 6 now holds tile 4, not tile 5)
CLS-ம் patch row-களின் mean-ம் இரண்டுமே முழு set மேல் நடக்கும் read-கள், எனவே இரண்டுமே தப்பிக்கின்றன. ஒரு தனி patch row என்பது ஒரு position-ல் நடக்கும் read, எனவே அது நகர்கிறது. Paper-ன் appendix, CLS-க்குப் பதில் mean-pooling முயற்சித்து, learning rate-ஐ மறு-tune செய்தால் அதுவும் சமமாக வேலை செய்கிறது என்று கண்டது; CLS token என்பது ஒரு BERT பழக்கம், கட்டாயம் அல்ல.
Position embedding illainaa, tiles-a shuffle pannaalum CLS row adhe dhaan — model-kku layout theriyaadhu. CLS-um mean-pool-um set-oda read, adhanaala safe.
ஏன் 16×16: அந்த n² கணக்கு
Patch size-தான் இந்த paper சேர்க்கும் ஒரே dial, அதுவே எல்லாவற்றையும் தீர்மானிக்கிறது. Tokens n = (224/p)², ஒவ்வொரு attention layer-ம் ஒரு head-க்கு ஒரு n×n score matrix கட்டுகிறது. 224×224 படத்துக்கான கணக்கை script அச்சிடுகிறது:
patch per token tokens n n x n vs 16x16
1x1 3 50176 2,517,630,976 65536.00x
4x4 48 3136 9,834,496 256.00x
8x8 192 784 614,656 16.00x
16x16 768 196 38,416 1.00x
32x32 3072 49 2,401 0.06x
Patch-ஐ பாதியாக்குங்கள், tokens நான்கு மடங்கு, attention matrix பதினாறு மடங்கு. ஒரு token-க்கு ஒரு pixel என்றால் ஒரு layer-ல் ஒரு head-க்கு 2.5 பில்லியன் scores — அதனால்தான் யாரும் அதைச் செய்வதில்லை. 16×16 தருவது 196 tokens: ஒரு பத்தி, ஒரு புத்தகம் அல்ல. இந்த table-லிருந்து இரண்டு விஷயங்கள் விழுகின்றன. 16·16·3 = 768 என்பது சரியாக ViT-Base-ன் D, எனவே அதன் embedding matrix தற்செயலாகச் சதுரம். மேலும் model-களின் பெயர்கள்: ViT-L/16 என்றால் Large, patch 16; ViT-H/14 patch 14-ஐப் பயன்படுத்தி அதிக செலவில் அதிக tokens பெறுகிறது.
தவறான reshape சத்தமே போடாது
இதுதான் நீங்கள் உண்மையிலேயே ship செய்யப் போகும் bug. Cut-ஐயும் transpose-ஐயும்
தவிர்த்துவிட்டு நேராக img.reshape(16, 192) செய்யுங்கள். NumPy
படத்தை row வரிசையாகப் படிக்கிறது, எனவே ஒவ்வொரு "patch"-ம் 8×8 சதுரம் அல்ல,
முழு அகலம் கொண்ட இரண்டு பட்டைகள். Shape ஒன்றுதான். எதுவும் error தராது.
shapes: (16, 192) (16, 192) equal? True
patch 5, first 8 values (R G B R G B R G):
right: [0.258 0.258 0. 0.29 0.258 0. 0.323 0.258]
wrong: [0. 0.323 1. 0.032 0.323 1. 0.065 0.323]
what is 'patch 5', really?
right == img[8:16, 8:16] an 8x8 square? True
wrong == img[10:12, :] a 2x32 strip? True
model runs on the wrong patches anyway: logits (10,)
பொம்மைப் படத்தில் சிவப்பு இடமிருந்து வலமாக ஏறுகிறது, பச்சை மேலிருந்து கீழாக. சரியான row pixel (8, 8)-ல் தொடங்குகிறது, எனவே சிவப்பும் பச்சையும் இரண்டும் 0.258. தவறான row pixel (10, 0)-ல் தொடங்குகிறது: சிவப்பு 0, பச்சை 0.323, நீலம் தவறான checkerboard கட்டத்தில். ஒரே shape, வேறு படம், நம்பும்படியான பத்து logits. அது train கூட ஆகும்; accuracy மட்டும் அமைதியாக மோசமாக இருக்கும், நீங்கள் learning rate-ஐக் குற்றம் சொல்வீர்கள். இதன் PyTorch வடிவம் — channel axis-ஐ முதலில் நகர்த்தாமல் ஒரு (C, H, W) tensor-ல் இதே செய்முறையை ஓட்டுவது. உங்களுக்கு வேண்டிய தோல்வி சத்தமானது: 32-ஐ 5 வகுக்காது, cut நடக்காது, NumPy மறுக்கிறது. உண்மையான pipeline-கள் patchify செய்வதற்கு முன் 224-க்கு resize செய்வது சரியாக இதே காரணத்துக்காகத்தான்.
p=8: patches (16, 192)
p=5: ValueError: cannot reshape array of size 3072 into shape (6,5,6,5,3)
मैंने image classifiers ship किए हैं: ResNets fine-tune किए, ONNX में export किया, code review में input normalisation पर बहस की। फिर transformers ने vision को निगल लिया और मैंने मान लिया कि pixels के लिए कोई नई तरकीब होगी। नहीं है। image को tiles में काटो, हर tile को एक row में flatten करो, और वे rows उसी encoder को दे दो जो attention page पर है। असली engineering यह है कि वह reshape क्या फेंक देता है, और फिर भी क्यों जीतता है।
एक image को tokenise करना
यह pipeline आप जानते हैं। हर language model यहीं से शुरू होता है:
ids = tokenizer("the cat ate food") # [464, 3797, 8268, 2057]
tokens = embedding_table[ids] # (4, D) हर word का एक row
out = transformer(tokens) # (4, D) वही shape, अब context में
यह paper वही pipeline है, बस tokeniser अलग है। Words एक vocabulary से आते हैं; tiles एक grid से:
patches = patchify(image, p=16) # (196, 768) 196 tiles, 16*16*3
tokens = patches @ E # (196, D) matmul, lookup नहीं
tokens = concat([cls, tokens]) + pos # (197, D) +1 row, +क्रम
out = transformer(tokens) # (197, D) 2017 से अपरिवर्तित
Text से सिर्फ़ दो फ़र्क़, और यही इस paper के इकलौते image-specific विचार हैं। कोई vocabulary नहीं: एक tile 768 सतत संख्याएँ हैं, कोई id नहीं — इसलिए embedding एक matmul है, lookup नहीं। और इस tokeniser का कोई नियम नहीं है। वह बस एक grid है।
| शब्द | मतलब | Programmer की उपमा |
|---|---|---|
| Patch | image का एक p×p×3 tile | एक word |
| Flatten | वही tile p·p·3 संख्याओं की एक row के रूप में | उस word के bytes |
| E | एक (p·p·3, D) matrix जिससे हर row गुणा होती है | Embedding table — vocab न होने से lookup की जगह matmul |
| CLS token | Sequence के आगे जोड़ी गई एक सीखी हुई row | एक sentinel। वही row जो अंत में पढ़ी जाती है |
| Position embedding | Rows में जोड़ी जाने वाली सीखी हुई (N+1, D) table | List में index। इसके बिना list एक set है |
इसने CNN को क्यों हराया — और कब नहीं
एक convolution sliding window पर चलता for loop है:
for y, x in every_pixel:
out[y, x] = kernel · img[y-1:y+2, x-1:x+2] # सिर्फ़ 3x3 पड़ोस,
# हर (y, x) पर वही kernel
उस loop में दो मान्यताएँ पकी हुई हैं: पास के pixels दूर वालों से ज़्यादा मायने रखते हैं, और कोने वाली बिल्ली वही बिल्ली है जो बीच में है। CNN को ये दोनों अपनी बनावट से मुफ़्त मिलती हैं। ViT को एक भी नहीं: tile 0 और tile 195 सिर्फ़ उन attention weights से जुड़े हैं जो उसे सीखने पड़ते हैं, और "पास के pixels मायने रखते हैं" यह भी उसे data से खोजना पड़ता है। Paper का वाक्यांश है much less image-specific inductive bias। कम मान्यताएँ एक सौदा हैं: data कम हो तो ख़राब, और जब data इतना हो कि मान्यताएँ ख़ुद सीखी जा सकें तो उससे भी बेहतर। अकेले ImageNet-1k पर ViT उसी आकार के ResNet से नीचे रहता है। ImageNet-21k (14M images) या JFT-300M पर pretrain करने पर वह जीतता है; सबसे बड़ा ViT-H/14 paper की मुख्य table में ImageNet पर 88.55% के साथ सबसे ऊपर है। Interview का सवाल है "ViT को CNN से ज़्यादा data क्यों चाहिए?", और जवाब वही inductive bias वाला वाक्य है।
वह एक formula
Equation 1। इसे बाएँ से दाएँ shapes की तरह पढ़िए — 224×224 image, patch 16, ViT-Base के लिए:
| चरण | Shape | क्या करता है |
|---|---|---|
x | (224, 224, 3) | Image। Height, width, channel |
xp | (196, 768) | वही reshape। N = 224·224 / 16² = 196 tiles, हर एक में 16·16·3 = 768 संख्याएँ |
E | (768, 768) | Patch embedding। वर्गाकार सिर्फ़ इसलिए कि ViT-Base ने D = 768 चुना |
xpE | (196, 768) | 196 tokens। यहाँ से आगे यह एक वाक्य है |
xclass | (1, 768) | सीखी हुई CLS row, आगे जोड़ी गई → (197, 768) |
+ Epos | (197, 768) | सीखी हुई, 1D, हर slot के लिए एक vector। जोड़ी जाती है, concat नहीं |
Equations 2 और 3 encoder block हैं — Base में 12 गहरा, Large में 24, Huge में 32; Equation 4 कहता है कि आख़िरी LayerNorm के बाद row 0 पढ़ो। दोनों attention page पर पहले से हैं। 2017 से एक ही बदलाव: LayerNorm हर sublayer से पहले चला गया (pre-norm), और MLP GELU इस्तेमाल करता है। Fine-tune के समय paper बड़ी image देता है, patch size वही रखता है इसलिए tokens ज़्यादा हो जाते हैं, और सीखी हुई position table को नए grid पर 2D-interpolate करता है; वे ख़ुद लिखते हैं कि हाथ से 2D मान्यता डालने की यही इकलौती जगह है।
वह reshape, हर shape छपी हुई
Script एक 32×32×3 खिलौना image और 8×8 patches लेता है ताकि संख्याएँ पढ़ी जा सकें: 192 संख्याओं वाले 16 tiles। तीन क़दम। Cut — हर spatial axis को (कितने tiles, हर tile में कितने pixels) में बाँटो। Group — "कौन सा tile" वाले दो axes साथ लाओ और "अंदर कौन सा pixel" वाले दो axes साथ; यही transpose है। Flatten — हर tile की एक row। Demo 1 इसे एक-एक line करके करता है ताकि आप हर क़दम पर breakpoint लगा सकें।
मेरी बात पर मत जाइए। यह रहा असली output:
image (32, 32, 3) (H, W, C)
cut (4, 8, 4, 8, 3) (H/p, p, W/p, p, C)
grouped (4, 4, 8, 8, 3) (H/p, W/p, p, p, C)
patches (16, 192) (N, p*p*C) <- the sentence
tokens (16, 64) (N, D) patches @ E
+ cls (17, 64) (N+1, D) BERT-style
+ pos (17, 64) (N+1, D) added
block (17, 64) (N+1, D) shape unchanged
attn (17, 17) (N+1, N+1) all patches see all
cls out (64,) (D,) the row we read
logits (10,) (n_classes,)
unpatchify(patchify(img)) == img? True
3072 संख्याएँ अंदर, 3072 बाहर, 16 rows में फिर से समूहबद्ध। patches @ E
तक कुछ भी गणना नहीं होती। (16, 64) से आगे shapes वही हैं जो
attention page के (n_tokens, d_model) हैं, और 17×17 attention
matrix का मतलब है हर tile हर tile को देखता है, साथ में CLS भी। नीचे वाला
round-trip वही test है जो मैं लिखता: अगर वह fail हुआ तो reshape ग़लत है, shapes
चाहे जो कहें।
Position: tiles की एक बोरी
Attention एक set operation है। Attention page ने इसे "cat ate food" बनाम "food ate cat" से साबित किया; यहाँ यह टुकड़े बदली हुई jigsaw है। 16 tiles को फेंटकर नई image बनाइए, दोनों चलाइए, और position table off तथा on करके CLS row की तुलना कीजिए:
tile permutation: [ 1 12 7 10 14 4 5 8 0 9 2 13 11 6 3 15]
shuffled image == original? False
position embeddings OFF: CLS max abs diff = 1.44e-15 allclose? True ← अंधा
position embeddings ON : CLS max abs diff = 0.0495 allclose? False ← दिखता है
वह 1.44e-15 शून्य नहीं है, और मैं यह नहीं कहूँगा कि है। Shuffle से attention का
जोड़ किस क्रम में होता है वह बदल जाता है, और float64 अलग क्रम में अलग round
करता है। पंद्रह दशमलव स्थान किसी भी test के लिए "एक जैसा" है; position on करने
पर मिला 0.0495 उससे तेरह गुना परिमाण बड़ा है। वह + self.pos ही
इकलौती चीज़ है जो model को बताती है कि tile कहाँ था। Paper इस table को
जानबूझकर बुद्धू रखता है: सीखी हुई, 1D, हर slot के लिए एक vector, list index की
तरह। उन्होंने 2D-aware versions आज़माए और कोई फ़ायदा नहीं मिला, क्योंकि सादा
table ख़ुद ही grid सीख लेता है; paper के inspection हिस्से में वह तस्वीर है,
जहाँ सीखे हुए position vectors की आपसी समानता rows और columns बना देती है।
वही shuffle यह भी तय कर देता है कि अंत में कौन सी row पढ़नी है। BERT-style में row 0, पर तीन स्पष्ट उम्मीदवार हैं:
max abs diff under the shuffle, position embeddings OFF:
z[0] CLS token 1.44e-15
z[1:].mean mean-pool 6.66e-16
z[6] patch token 6 1.47
(token 6 now holds tile 4, not tile 5)
CLS और patch rows का mean, दोनों पूरे set पर किए गए reads हैं, इसलिए दोनों बच जाते हैं। अकेली patch row एक position पर किया गया read है, इसलिए वह हिल जाती है। Paper के appendix ने CLS की जगह mean-pooling आज़माया और पाया कि learning rate दोबारा tune करने पर वह भी उतना ही अच्छा चलता है; CLS token एक BERT आदत है, ज़रूरत नहीं।
16×16 ही क्यों: n² का बिल
Patch size ही इकलौता dial है जो यह paper जोड़ता है, और वही सब कुछ तय करता है। Tokens n = (224/p)², और हर attention layer हर head के लिए एक n×n score matrix बनाता है। Script 224×224 image का बिल छापता है:
patch per token tokens n n x n vs 16x16
1x1 3 50176 2,517,630,976 65536.00x
4x4 48 3136 9,834,496 256.00x
8x8 192 784 614,656 16.00x
16x16 768 196 38,416 1.00x
32x32 3072 49 2,401 0.06x
Patch आधा करो, tokens चार गुना, attention matrix सोलह गुना। हर token पर एक pixel का मतलब होगा प्रति head प्रति layer 2.5 अरब scores — इसीलिए कोई ऐसा नहीं करता। 16×16 से 196 tokens मिलते हैं: एक पैराग्राफ़, पूरी किताब नहीं। इस table से दो बातें निकलती हैं। 16·16·3 = 768 ठीक ViT-Base का D है, इसलिए उसका embedding matrix संयोग से वर्गाकार है। और models के नाम: ViT-L/16 यानी Large with patch 16; ViT-H/14 patch 14 लेता है, ज़्यादा ख़र्च पर ज़्यादा tokens।
ग़लत reshape चुपचाप होता है
यही वह bug है जो आप सचमुच ship करेंगे। Cut और transpose छोड़कर सीधे
img.reshape(16, 192) कर दीजिए। NumPy image को row दर row पढ़ता
है, इसलिए हर "patch" 8×8 वर्ग नहीं, पूरी चौड़ाई की दो पट्टियाँ बनता है। Shape
बिलकुल वही रहता है। कुछ भी throw नहीं होता।
shapes: (16, 192) (16, 192) equal? True
patch 5, first 8 values (R G B R G B R G):
right: [0.258 0.258 0. 0.29 0.258 0. 0.323 0.258]
wrong: [0. 0.323 1. 0.032 0.323 1. 0.065 0.323]
what is 'patch 5', really?
right == img[8:16, 8:16] an 8x8 square? True
wrong == img[10:12, :] a 2x32 strip? True
model runs on the wrong patches anyway: logits (10,)
खिलौना image में लाल बाएँ से दाएँ चढ़ता है, हरा ऊपर से नीचे। सही row pixel (8, 8) से शुरू होती है, इसलिए लाल और हरा दोनों 0.258 पढ़ते हैं। ग़लत row pixel (10, 0) से शुरू होती है: लाल 0, हरा 0.323, और नीला ग़लत checkerboard खाने पर। वही shape, अलग तस्वीर, दस भरोसेमंद दिखने वाले logits। यह train भी हो जाएगा; accuracy बस चुपचाप ख़राब रहेगी और आप learning rate को दोष देंगे। इसका PyTorch रूप है — channel axis पहले हटाए बिना यही नुस्ख़ा किसी (C, H, W) tensor पर चलाना। जो failure आपको चाहिए वह शोर वाला है: 32 में 5 का भाग नहीं जाता, cut हो ही नहीं सकता, NumPy मना कर देता है। असली pipelines patchify से पहले 224 पर resize इसी वजह से करती हैं।
p=8: patches (16, 192)
p=5: ValueError: cannot reshape array of size 3072 into shape (6,5,6,5,3)
The code
Pure NumPy. No torch, no autograd, no training — just the forward pass, so
you can watch the reshape happen. Every function under 20 lines.
softmax, attention and layernorm are
the attention page's, unchanged: in the script, not repeated here.
Download the full script — 160 lines plus six runnable demos. Then:
python3 vit_from_scratch.py
import numpy as np
def patchify(img, p):
H, W, C = img.shape
# (1) CUT: split each spatial axis into (how many patches, pixels each).
# (H, W, C) -> (H/p, p, W/p, p, C). Throws if p does not divide H, W.
cut = img.reshape(H // p, p, W // p, p, C)
# (2) GROUP: bring the two "which patch" axes together, and the two
# "which pixel inside it" axes together. This transpose is the line
# everyone skips. Skipping it is silent garbage (demo 5).
grouped = cut.transpose(0, 2, 1, 3, 4) # (H/p, W/p, p, p, C)
# (3) FLATTEN: one row per patch. p*p pixels, R G B R G B ... in order.
return grouped.reshape((H // p) * (W // p), p * p * C)
class EncoderBlock:
def __init__(self, D, seed):
rng = np.random.default_rng(seed)
s = 1 / np.sqrt(D)
self.W_q, self.W_k, self.W_v, self.W_o = [
rng.normal(0, s, (D, D)) for _ in range(4)]
self.W_1 = rng.normal(0, s, (D, 4 * D)) # MLP: D -> 4D -> D
self.W_2 = rng.normal(0, 1 / np.sqrt(4 * D), (4 * D, D))
def __call__(self, z):
h = layernorm(z) # LN first
a, weights = attention(h @ self.W_q, h @ self.W_k, h @ self.W_v)
z = z + a @ self.W_o # residual: a diff
z = z + gelu(layernorm(z) @ self.W_1) @ self.W_2 # MLP, residual
return z, weights
class ViT:
def __init__(self, H, W, C, p, D, n_blocks, n_classes, seed=0):
rng = np.random.default_rng(seed)
self.p, self.N = p, (H // p) * (W // p)
self.E = rng.normal(0, 1 / np.sqrt(p * p * C), (p * p * C, D))
self.cls = rng.normal(0, 0.02, (1, D))
self.pos = rng.normal(0, 0.02, (self.N + 1, D))
self.blocks = [EncoderBlock(D, seed=i + 1) for i in range(n_blocks)]
self.W_head = rng.normal(0, 1 / np.sqrt(D), (D, n_classes))
def __call__(self, img, use_pos=True):
return self.forward(patchify(img, self.p), use_pos) # (N, p*p*C) in
def forward(self, patches, use_pos=True):
tokens = patches @ self.E # (N, D) x_p E
z = np.concatenate([self.cls, tokens], axis=0) # (N+1, D) prepend
if use_pos:
z = z + self.pos # (N+1, D) + E_pos
for block in self.blocks:
z, weights = block(z) # (N+1, D) unchanged
z = layernorm(z) # Eq. 4: LN(z_L^0)
return z[0] @ self.W_head, z, weights # logits, rows, attn
Now break it
This is the part that actually makes it stick. Reading won't do it. You debug for a living — reverse-engineering from broken behaviour is your native mode.
| Break this | Watch for |
|---|---|
Call patchify(img, 5) | ValueError: cannot reshape array of size 3072 into shape (6,5,6,5,3). Loud, immediate, at the first line. This is the good failure. |
Delete z = z + self.pos | Shuffle the tiles and the CLS row is unchanged: max abs diff 1.44e-15, allclose? True. The same answer for any tile order. It can't see layout at all. |
In ViT.__call__, swap patchify for patchify_wrong | Nothing. Shapes match, logits (10,), no error. "Patch 5" is now img[10:12, :], a 2×32 strip. Silent, and it'll train. |
Read z[6] instead of z[0] for the head | Under the shuffle it moves by 1.47 while CLS and z[1:].mean(0) stay at 1e-15. Mean-pooling is a legitimate head (the paper's appendix, with a re-tuned learning rate). One patch row is not. |
| Train it on a small dataset | You can't, here: there is no training loop. The paper's finding is that on ImageNet-1k alone ViT trails a comparable ResNet, and only overtakes it after ImageNet-21k or JFT-300M pretraining, because it has less image-specific inductive bias and must learn locality from data. |
On the maths anxiety
There is no new maths in this paper. If you cleared the attention page you have cleared this one: a reshape and a transpose are array indexing, the patch embedding is one matmul, and the rest is the same softmax and weighted average as before.
The one thing worth being fluent in is the arithmetic in the n² table: tokens from patch size, attention cost from tokens, so memory and latency from patch size. That's the sum you'll do at work when someone asks whether the model fits on the GPU at 384×384, and it's three multiplications. You don't need to know why 1D position embeddings learn a 2D grid. Nobody fully does.
Where to go next
This page gave you one thing: an image as a sentence, in code you can break. These fill in what I deliberately skipped.
-
An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale
§3.1 is the script with Greek letters: Equations 1 to 4 are
patchify, the block, and the head. §4.3 has the plots that answer "how much data", and the appendix has the ablations I quoted on CLS versus mean-pool and 1D versus 2D positions. -
vit-pytorch
The step from my NumPy to something you'd train. Multi-head, dropout, a real head, and the whole of my
patchifyas oneeinops.rearrangecall. Diff it against the script. -
Fine-Tune ViT for Image Classification with Transformers
Nobody pretrains a ViT on JFT-300M at work. You take a pretrained one and fine-tune it on your own images, which is the paper's own recommendation. End to end, with the processor that does the resize-to-224 I mentioned.