BERT: Pre-training of Deep Bidirectional Transformers
Fill-in-the-blank on the whole internet. The attention page ends with "remove the mask, that's BERT". This is the rest of that sentence.
Download bert_from_scratch.py — everything below runs from it. NumPy, plus a tiny CPU PyTorch model for the training step.
I'd fine-tuned BERT for a classifier before I understood what pre-training
had done to it. Load bert-base-uncased, add a head, train three
epochs, ship. What nobody told me is that the paper is one exam question I
sat at age nine, plus the encoder you already
have with one argument deleted.
Pre-training is a cloze test
You did this at school:
sentence = "the cat sat on the mat"
question = "the cat ___ on the mat" # the blank
answer = "sat" # the teacher has the original
BERT is that, over 3.3 billion words, with a transformer encoder as the pupil and the loss graded only on the blanks:
for sentence in the_internet:
hidden, answers = hide_15_percent(sentence) # blank some tokens
guess = encoder(hidden) # every token sees BOTH sides
loss = cross_entropy(guess, answers) # marked on the blanks only
That is pre-training: no task in mind, just what tends to sit between "the
cat" and "on the mat". Then, per task, bolt a small head on and train the
whole thing a little more. That is fine-tuning. Same library, a different
main() per task.
| Term | Means | Programmer's analogy |
|---|---|---|
| MLM | Masked language model: hide tokens, predict them | A cloze test |
| Bidirectional | Each token attends to its left and its right | No causal mask. Just mask=None |
| Pre-train / fine-tune | Learn the language once; then add a head and train every parameter per task | One shared library, a different main() per task |
| [CLS] | An extra first token whose output stands for the whole input | A reduce() over the sequence into one struct |
Why it beat GPT-1 and ELMo
A normal language model predicts token 5 from tokens 1–4, then 6 from 1–5.
Every token sees only its left. GPT-1 was exactly that. Fine for generating
text, but "bank" in "the bank of the river" has its best clue on the
right. So why not let every token see both sides? Because it
cheats: in a stacked encoder, token 5 in layer 2 reads token 6 from layer
1, which has already read token 5. The paper says each word could
"indirectly see itself". ELMo dodged this with one left-to-right LSTM and
one right-to-left, glued at the end: a "shallow concatenation". BERT's one
trick: if the token is hidden, there's nothing to leak. Replace it
with [MASK], let everyone look everywhere, and the only way to
score is to infer it from both sides. The cloze test is not a cute
framing. It is what makes deep bidirectionality legal.
The one formula
x is the original tokens, x̃ the same sequence with 15% of it tampered with, M the set of tampered positions. Read it as stages:
| Step | Shape | What it does |
|---|---|---|
x | (L,) | Token ids of one sequence. L is 512 in the paper, 17 in my script |
x̃ = corrupt(x) | (L,) | Pick 15% of positions. Of those, 80% become [MASK], 10% a random token, 10% untouched |
encoder(x̃) | (L, d) | The attention stack from the attention page, with mask=None. Every row sees every column |
head | (L, V) | One linear layer to vocabulary logits: "which token was here?" |
−log p at i ∈ M | scalar | Cross-entropy, but only at the tampered positions. The other 85% are context, not questions |
The masking recipe: 15%, then 80/10/10
The odd part is the 80/10/10. Why not replace every picked token with
[MASK]? Because [MASK] never appears when you
fine-tune. The 10% random and 10% kept-as-is mean the model is sometimes
marked on a visible token, so it can never fully trust its input.
Train without them and accuracy on a visible token falls from 99.3% to
53.0%: a row in the break-it table, and an ablation in the paper's appendix.
The recipe in NumPy, one sentence and then ten thousand:
original : [CLS] d D e E g G h H a A b B g G h H
corrupted: [CLS] d D e _ g G _ H a A b B g G h _
labels : . . . . E . . h . . . . . . . . H
kind : . . . . M . . M . . . . . . . . M
(M = [MASK] R = random token K = kept as-is . = no loss)
over 10000 sentences (160000 real tokens):
[MASK] 12.0% (recipe says 12.0%)
random 1.5% (recipe says 1.5%)
kept 1.5% (recipe says 1.5%)
picked 15.0% (recipe says 15.0%) ✓
My toy corpus: a "sentence" is eight bracket pairs, an opener
(a–h) followed by its own closer
(A–H), openers chosen at random. So a closer is
predictable from its left, an opener only from its right.
Deliberate: half the blanks need right context.
Drop the mask and every token sees both sides
The attention page's scaled_dot_product_attention, same input,
one argument changed. Row = a token; column = how much of another it reads.
no mask (BERT) -- each row = what that token can see:
b [0.79 0.01 0.04 0.04 0.11] sees 5 of 5
B [0.02 0.86 0.04 0.03 0.05] sees 5 of 5
e [0.20 0.10 0.28 0.21 0.22] sees 5 of 5 ← reads the E to its right
E [0.20 0.07 0.25 0.27 0.21] sees 5 of 5
a [0.39 0.09 0.17 0.13 0.23] sees 5 of 5
causal mask (GPT) -- each row = what that token can see:
b [1.00 0.00 0.00 0.00 0.00] sees 1 of 5
B [0.03 0.97 0.00 0.00 0.00] sees 2 of 5
e [0.34 0.17 0.49 0.00 0.00] sees 3 of 5 ← has no idea an E is coming
E [0.26 0.09 0.32 0.34 0.00] sees 4 of 5
a [0.39 0.09 0.17 0.13 0.23] sees 5 of 5
mask=None is BERT;
mask=causal_mask(n) is GPT. The last row is identical in
both: the final token always sees everything, which is why a causal model
is fine for generation. BERT wants every token to have that view.
Train it: loss falls, the blanks fill in
The encoder is two of the attention page's blocks in PyTorch, 32 wide, two heads, 27k parameters. BERT-base is twelve blocks, 768 wide, twelve heads, 110M parameters, a million steps on BooksCorpus plus Wikipedia. Same shape, smaller numbers. Six hundred steps on a laptop CPU:
step 1 loss 3.083 masked-token accuracy 5.3%
step 200 loss 0.289 masked-token accuracy 89.4%
step 400 loss 0.429 masked-token accuracy 89.6%
step 600 loss 0.407 masked-token accuracy 89.4% ✓
partner visible and intact : 99.9% (4172 targets)
partner hidden or corrupted: 18.3% (613 targets, chance = 12.5%)
Why 89% and not 100%? The last two lines say why. Partner visible: near perfect. Both halves of a pair picked: nothing left to infer from. That 11% is the recipe, not the model.
A held-out sentence, blanks filled in by the model:
original : [CLS] a A e E h H a A g G g G c C f F
input : [CLS] a A e E h H a _ g G g G c b f _
predicted: [CLS] a A e E h H a A g G g G c C f F ✓✓✓
Two blanks filled. And the b after c was a random
replacement, not a blank; the model still corrected it. The 10% random rule.
Keep the mask and watch it fail
Now the paper's actual argument. Same model, same data stream, same seed,
same 600 steps. The only change is causal=True: the attention
page's lower-triangular mask, put back.
bidirectional causal
all masked tokens 89.4% 55.5%
closers (clue on the LEFT) 89.6% 89.7% ✓
openers (clue on the RIGHT) 89.3% 20.4%
chance = 12.5%
Look at the split. The causal model closes brackets as well as BERT does; the opener is on its left. It cannot guess an opener: the only evidence is the closer on its right, and the mask hides it. 20%, barely above random. Half of language has its clue on the right. That is the paper's entire claim, in one row.
[CLS], NSP, and a different main() per task
BERT prepends one extra token, [CLS], to every input. No
meaning; a slot. Its output at the top of the stack is "the sentence":
encoder output (batch, 17, 32), row 0 is (batch, 32), one linear layer
gives (batch, 2). That's the whole classification head.
The paper's second pre-training task, next sentence
prediction, gives that slot a job. Take sentences A and B; half
the time B really followed A, half the time it's random. Feed
[CLS] A [SEP] B [SEP] plus a learned segment embedding, and
train the [CLS] output to say IsNext or
NotNext. The ablation in §5.1 says dropping it hurts QNLI, MNLI
and SQuAD. RoBERTa, a year later, trained without NSP on more data and
matched or beat BERT, so the field dropped it. I didn't implement it, and
you can see the cost: [CLS] has done nothing in pre-training.
Fine-tuning fixes that, but only if the gradients reach the encoder. Toy task, a miniature CoLA: is this sentence grammatical? Half have one closer swapped for a wrong one.
300 steps of fine-tuning trainable params accuracy
head only, encoder frozen 66 51.8%
everything (the paper) 27188 99.2%
everything, NO pre-training 27188 50.1%
chance = 50.0%
Three rows, both of the paper's claims. Freeze the encoder: chance. Fine-tune everything: solved in 300 steps. Fine-tune everything from a random encoder for the same 300 steps: chance again. Pre-training makes fine-tuning cheap; fine-tuning all of it makes pre-training usable. The paper does both, on all 110M parameters, at 2e-5 to 5e-5 for two to four epochs. In my toy the frozen head gets nothing; in the paper's own feature-based experiment on NER it gets most of the way, because twelve layers of real features with NSP behind them beat my 32 numbers.
Pre-training என்ன செய்தது என்று புரிவதற்கு முன்பே நான் ஒரு classifier-க்காக
BERT-ஐ fine-tune செய்திருந்தேன். bert-base-uncased load செய்,
ஒரு head சேர், மூன்று epochs train செய், ship. ஆனால் யாரும் சொல்லாதது ஒன்று:
இந்த paper என்பது நான் ஒன்பது வயதில் எழுதிய ஒரு தேர்வுக் கேள்வி, கூடவே
உங்களிடம் ஏற்கனவே இருக்கும் encoder — அதில்
ஒரே ஒரு argument-ஐ நீக்கியது.
Pre-training என்பது ஒரு கோடிட்ட இடத்தை நிரப்புக
பள்ளியில் இதைச் செய்திருப்பீர்கள்:
sentence = "the cat sat on the mat"
question = "the cat ___ on the mat" # கோடிட்ட இடம்
answer = "sat" # மூலவாக்கியம் ஆசிரியரிடம்
BERT அதுதான் — 3.3 பில்லியன் வார்த்தைகள் மேல், மாணவனாக ஒரு transformer encoder, மேலும் loss கோடிட்ட இடங்களுக்கு மட்டும் மதிப்பெண்:
for sentence in the_internet:
hidden, answers = hide_15_percent(sentence) # சில tokens-ஐ மறை
guess = encoder(hidden) # token இரு பக்கமும் பார்க்கும்
loss = cross_entropy(guess, answers) # மறைத்தவற்றுக்கு மட்டும்
அதுதான் pre-training: எந்த task-ம் மனதில் இல்லை, "the cat"-க்கும் "on the
mat"-க்கும் இடையே பொதுவாக என்ன வரும் என்பது மட்டும். பிறகு ஒவ்வொரு
task-க்கும் ஒரு சிறிய head பொருத்தி, முழுவதையும் இன்னும் கொஞ்சம் train
செய்வது — அதுதான் fine-tuning. ஒரே library, task-க்கு ஒரு வேறு
main().
Pre-training-nu solradhu school-la panna fill-in-the-blank dhaan. 15% tokens mattum maraikkuradhu, encoder guess pannuradhu, blanks-kku mattum loss.
| சொல் | அர்த்தம் | Programmer-க்கான உதாரணம் |
|---|---|---|
| MLM | Masked language model: tokens-ஐ மறைத்து, அவற்றைக் கணிக்கச் சொல்வது | கோடிட்ட இடத்தை நிரப்புக |
| Bidirectional | ஒவ்வொரு token-ம் தன் இடது மற்றும் வலது பக்கத்தைப் பார்க்கிறது | Causal mask இல்லை. வெறும் mask=None |
| Pre-train / fine-tune | மொழியை ஒருமுறை கற்றுக்கொள்; பிறகு ஒரு head சேர்த்து ஒவ்வொரு task-க்கும் எல்லா parameter-ஐயும் train செய் | ஒரே பொது library, task-க்கு ஒரு வேறு main() |
| [CLS] | முதலில் சேர்க்கப்படும் ஒரு கூடுதல் token; அதன் output முழு input-ஐயும் குறிக்கிறது | Sequence மேல் ஒரு reduce(), ஒரே struct-ஆக |
GPT-1-ஐயும் ELMo-வையும் இது ஏன் வென்றது
சாதாரண language model, tokens 1–4-லிருந்து token 5-ஐக் கணிக்கும், பிறகு
1–5-லிருந்து 6-ஐ. ஒவ்வொரு token-ம் தன் இடதுபுறத்தை மட்டுமே பார்க்கும்.
GPT-1 அப்படித்தான். Text உருவாக்குவதற்கு இது நல்லது, ஆனால் "the bank of
the river"-ல் "bank"-க்கான சிறந்த குறிப்பு வலதுபுறம் இருக்கிறது.
அப்படியானால் ஒவ்வொரு token-ம் இரு பக்கமும் பார்க்கட்டுமே? ஏனெனில் அது
ஏமாற்றும்: அடுக்கப்பட்ட encoder-ல், layer 2-வில் உள்ள token 5, layer
1-லிருந்து token 6-ஐப் படிக்கிறது — அது ஏற்கனவே token 5-ஐப் படித்துவிட்டது.
ஒவ்வொரு வார்த்தையும் "மறைமுகமாகத் தன்னையே பார்க்கக்கூடும்" என்று paper
சொல்கிறது. ELMo இதை ஒரு left-to-right LSTM, ஒரு right-to-left LSTM, இறுதியில்
ஒட்டி — ஒரு "shallow concatenation" — என்று சமாளித்தது. BERT-இன் ஒரே தந்திரம்:
token மறைக்கப்பட்டால் கசிய ஒன்றுமில்லை. அதை [MASK]-ஆல்
மாற்று, எல்லோரையும் எங்கும் பார்க்க விடு — மதிப்பெண் பெற ஒரே வழி, இரு
பக்கத்திலிருந்தும் ஊகிப்பதுதான். இந்தக் கோடிட்ட இடம் வெறும் அழகான உவமை அல்ல.
ஆழமான bidirectionality-ஐ சட்டப்பூர்வமாக்குவது அதுதான்.
Rendu pakkamum paakka vitta, token thannaye paathukkum — adhu dhaan leak. Token-ai maraichaa leak illa. Andha oru trick dhaan BERT.
அந்த ஒரு சூத்திரம்
x என்பது மூல tokens, x̃ என்பது அதே sequence-ல் 15% சிதைக்கப்பட்டது, M என்பது சிதைக்கப்பட்ட இடங்களின் தொகுப்பு. படிநிலைகளாகப் படியுங்கள்:
| படி | Shape | என்ன செய்கிறது |
|---|---|---|
x | (L,) | ஒரு sequence-இன் token ids. Paper-ல் L = 512, என் script-ல் 17 |
x̃ = corrupt(x) | (L,) | 15% இடங்களைத் தேர்ந்தெடு. அவற்றில் 80% [MASK], 10% ஒரு random token, 10% தொடாமல் |
encoder(x̃) | (L, d) | Attention page-இன் attention stack, mask=None உடன். ஒவ்வொரு row-ம் ஒவ்வொரு column-ஐயும் பார்க்கிறது |
head | (L, V) | Vocabulary logits-க்கு ஒரே linear layer: "இங்கே எந்த token இருந்தது?" |
i ∈ M-ல் −log p | scalar | Cross-entropy, ஆனால் சிதைக்கப்பட்ட இடங்களில் மட்டும். மீதி 85% context, கேள்வி அல்ல |
Masking செய்முறை: 15%, பிறகு 80/10/10
விசித்திரமான பகுதி 80/10/10. தேர்ந்தெடுத்த ஒவ்வொரு token-ஐயும்
[MASK]-ஆல் ஏன் மாற்றக்கூடாது? ஏனெனில் fine-tune செய்யும்போது
[MASK] ஒருபோதும் வருவதில்லை. 10% random, 10% அப்படியே வைத்தல்
என்பதால், model சில நேரம் தெரியும் token-க்கும் மதிப்பெண் பெறுகிறது —
எனவே அது தன் input-ஐ முழுமையாக நம்ப முடியாது. அவற்றை நீக்கி train செய்தால்,
தெரியும் token-ல் accuracy 99.3%-லிருந்து 53.0%-ஆக விழுகிறது: break-it
அட்டவணையின் ஒரு row, மேலும் paper-இன் appendix-ல் ஒரு ablation.
NumPy-ல் அந்தச் செய்முறை — ஒரு வாக்கியம், பிறகு பத்தாயிரம்:
original : [CLS] d D e E g G h H a A b B g G h H
corrupted: [CLS] d D e _ g G _ H a A b B g G h _
labels : . . . . E . . h . . . . . . . . H
kind : . . . . M . . M . . . . . . . . M
(M = [MASK] R = random token K = kept as-is . = no loss)
over 10000 sentences (160000 real tokens):
[MASK] 12.0% (recipe says 12.0%)
random 1.5% (recipe says 1.5%)
kept 1.5% (recipe says 1.5%)
picked 15.0% (recipe says 15.0%) ✓
என் பொம்மை corpus: ஒரு "வாக்கியம்" என்பது எட்டு அடைப்புக் குறி ஜோடிகள் —
ஒரு opener (a–h), அதைத் தொடர்ந்து அதற்கான closer
(A–H), openers random-ஆகத் தேர்ந்தெடுக்கப்படுகின்றன.
எனவே ஒரு closer-ஐ அதன் இடதுபுறத்திலிருந்து கணிக்கலாம், ஒரு
opener-ஐ அதன் வலதுபுறத்திலிருந்து மட்டுமே. இது வேண்டுமென்றே:
பாதி கோடிட்ட இடங்களுக்கு வலது context தேவை.
Mask-ஐ நீக்கு, ஒவ்வொரு token-ம் இரு பக்கமும் பார்க்கும்
Attention page-இன் scaled_dot_product_attention, அதே input,
ஒரே ஒரு argument மாற்றம். Row = ஒரு token; column = அது இன்னொன்றை எவ்வளவு
படிக்கிறது.
no mask (BERT) -- each row = what that token can see:
b [0.79 0.01 0.04 0.04 0.11] sees 5 of 5
B [0.02 0.86 0.04 0.03 0.05] sees 5 of 5
e [0.20 0.10 0.28 0.21 0.22] sees 5 of 5 ← வலது E-ஐப் படிக்கிறது
E [0.20 0.07 0.25 0.27 0.21] sees 5 of 5
a [0.39 0.09 0.17 0.13 0.23] sees 5 of 5
causal mask (GPT) -- each row = what that token can see:
b [1.00 0.00 0.00 0.00 0.00] sees 1 of 5
B [0.03 0.97 0.00 0.00 0.00] sees 2 of 5
e [0.34 0.17 0.49 0.00 0.00] sees 3 of 5 ← E வருவது தெரியாது
E [0.26 0.09 0.32 0.34 0.00] sees 4 of 5
a [0.39 0.09 0.17 0.13 0.23] sees 5 of 5
mask=None என்றால்
BERT; mask=causal_mask(n) என்றால் GPT. கடைசி row இரண்டிலும்
ஒன்றுதான்: கடைசி token எப்போதும் எல்லாவற்றையும் பார்க்கிறது — அதனால்தான்
causal model generation-க்கு நல்லது. BERT-க்கு ஒவ்வொரு token-க்கும் அந்தப்
பார்வை வேண்டும்.
Train செய்: loss குறைகிறது, கோடிட்ட இடங்கள் நிரம்புகின்றன
Encoder என்பது attention page-இன் இரண்டு blocks, PyTorch-ல், 32 அகலம், இரண்டு heads, 27k parameters. BERT-base என்பது பன்னிரண்டு blocks, 768 அகலம், பன்னிரண்டு heads, 110M parameters, BooksCorpus மற்றும் Wikipedia மேல் ஒரு மில்லியன் steps. அதே வடிவம், சிறிய எண்கள். Laptop CPU-ல் அறுநூறு steps:
step 1 loss 3.083 masked-token accuracy 5.3%
step 200 loss 0.289 masked-token accuracy 89.4%
step 400 loss 0.429 masked-token accuracy 89.6%
step 600 loss 0.407 masked-token accuracy 89.4% ✓
partner visible and intact : 99.9% (4172 targets)
partner hidden or corrupted: 18.3% (613 targets, chance = 12.5%)
ஏன் 89%, 100% இல்லை? கடைசி இரண்டு வரிகள் காரணத்தைச் சொல்கின்றன. Partner தெரிந்தால்: கிட்டத்தட்ட சரியானது. ஒரு ஜோடியின் இரு பாதிகளும் தேர்ந்தெடுக்கப்பட்டால்: ஊகிக்க எதுவும் மிச்சமில்லை. அந்த 11% என்பது செய்முறை, model அல்ல.
ஒரு held-out வாக்கியம், model நிரப்பிய கோடிட்ட இடங்கள்:
original : [CLS] a A e E h H a A g G g G c C f F
input : [CLS] a A e E h H a _ g G g G c b f _
predicted: [CLS] a A e E h H a A g G g G c C f F ✓✓✓
இரண்டு கோடிட்ட இடங்கள் நிரம்பின. மேலும் c-க்குப் பிறகு வந்த
b ஒரு random replacement, கோடிட்ட இடம் அல்ல; model அதையும்
திருத்தியது. அதுதான் 10% random விதி.
Mask-ஐ வைத்தே இருந்து, அது தோற்பதைப் பார்
இப்போது paper-இன் உண்மையான வாதம். அதே model, அதே data stream, அதே seed,
அதே 600 steps. ஒரே மாற்றம் causal=True: attention page-இன்
lower-triangular mask, மீண்டும் வைக்கப்பட்டது.
bidirectional causal
all masked tokens 89.4% 55.5%
closers (clue on the LEFT) 89.6% 89.7% ✓
openers (clue on the RIGHT) 89.3% 20.4%
chance = 12.5%
அந்தப் பிரிவைப் பாருங்கள். Causal model, BERT அளவுக்கே நன்றாக அடைப்புகளை மூடுகிறது; opener அதன் இடதுபுறத்தில் இருக்கிறது. ஆனால் அதனால் ஒரு opener-ஐ ஊகிக்க முடியாது: ஒரே ஆதாரம் அதன் வலதுபுறம் உள்ள closer, mask அதை மறைக்கிறது. 20%, random-ஐ விட சற்றே மேல். மொழியின் பாதிக்கு அதன் குறிப்பு வலதுபுறத்தில் இருக்கிறது. Paper-இன் முழு வாதமும் அந்த ஒரு row-ல்.
Causal model closers-ai nalla mudikkudhu, aana openers-la 20% dhaan. Clue right-la irundha, mask adha maraikkudhu. Adhu dhaan paper-oda point.
[CLS], NSP, மற்றும் task-க்கு ஒரு வேறு main()
BERT ஒவ்வொரு input-க்கும் முன்னால் [CLS] என்ற ஒரு கூடுதல்
token-ஐச் சேர்க்கிறது. அர்த்தம் இல்லை; ஒரு இடம் மட்டும். Stack-இன் உச்சியில்
அதன் output "அந்த வாக்கியம்": encoder output (batch, 17, 32), row 0 என்பது
(batch, 32), ஒரே linear layer (batch, 2) தருகிறது. அவ்வளவுதான் முழு
classification head.
Paper-இன் இரண்டாவது pre-training task, next sentence
prediction, அந்த இடத்துக்கு ஒரு வேலை தருகிறது. A மற்றும் B
வாக்கியங்களை எடு; பாதி நேரம் B உண்மையிலேயே A-ஐத் தொடர்ந்தது, பாதி நேரம் அது
random. [CLS] A [SEP] B [SEP] மற்றும் ஒரு கற்ற segment embedding
கொடுத்து, [CLS] output IsNext அல்லது NotNext
என்று சொல்லப் பயிற்றுவி. §5.1-இன் ablation, அதை நீக்கினால் QNLI, MNLI, SQuAD
பாதிக்கப்படும் என்கிறது. ஒரு வருடம் கழித்து RoBERTa, NSP இல்லாமல் அதிக
data-வில் train செய்து BERT-க்கு இணையாக அல்லது மேலாக வந்தது — எனவே இத்துறை
அதைக் கைவிட்டது. நானும் implement செய்யவில்லை, அதன் விலை தெரிகிறது:
pre-training-ல் [CLS] எதுவும் செய்யவில்லை.
Fine-tuning அதைச் சரிசெய்யும் — ஆனால் gradients encoder வரை சென்றால் மட்டுமே. ஒரு பொம்மை task, ஒரு சிறிய CoLA: இந்த வாக்கியம் இலக்கணப்படி சரியா? பாதியில் ஒரு closer தவறான ஒன்றால் மாற்றப்பட்டிருக்கும்.
300 steps of fine-tuning trainable params accuracy
head only, encoder frozen 66 51.8%
everything (the paper) 27188 99.2%
everything, NO pre-training 27188 50.1%
chance = 50.0%
மூன்று rows, paper-இன் இரு வாதங்களும். Encoder-ஐ freeze செய்: random அளவுதான். எல்லாவற்றையும் fine-tune செய்: 300 steps-ல் தீர்ந்தது. ஒரு random encoder-லிருந்து அதே 300 steps fine-tune செய்: மீண்டும் random அளவு. Pre-training fine-tuning-ஐ மலிவாக்குகிறது; எல்லாவற்றையும் fine-tune செய்வது pre-training-ஐப் பயனுள்ளதாக்குகிறது. Paper இரண்டையும் செய்கிறது, 110M parameters மேல், 2e-5 முதல் 5e-5 வரை, இரண்டு முதல் நான்கு epochs. என் பொம்மையில் frozen head-க்கு எதுவும் கிடைக்கவில்லை; paper-இன் NER மேல் செய்த feature-based சோதனையில் அது கிட்டத்தட்ட முழு தூரமும் செல்கிறது — ஏனெனில் NSP-உடன் பன்னிரண்டு layers-இன் உண்மையான features, என் 32 எண்களை விட மேல்.
मैंने एक classifier के लिए BERT fine-tune कर लिया था, यह समझने से पहले ही
कि pre-training ने उसके साथ किया क्या है। bert-base-uncased
load करो, एक head जोड़ो, तीन epochs train करो, ship। जो किसी ने नहीं बताया
वह यह है: यह paper वही एक परीक्षा-प्रश्न है जो मैंने नौ साल की उम्र में हल
किया था, और साथ में वही encoder जो आपके पास पहले से
है — बस एक argument हटा हुआ।
Pre-training एक रिक्त स्थान भरो है
यह आपने स्कूल में किया है:
sentence = "the cat sat on the mat"
question = "the cat ___ on the mat" # रिक्त स्थान
answer = "sat" # मूल वाक्य शिक्षक के पास है
BERT यही है — 3.3 अरब शब्दों पर, विद्यार्थी की जगह एक transformer encoder, और loss सिर्फ़ रिक्त स्थानों पर:
for sentence in the_internet:
hidden, answers = hide_15_percent(sentence) # कुछ tokens छिपाओ
guess = encoder(hidden) # हर token दोनों तरफ़ देखता है
loss = cross_entropy(guess, answers) # सिर्फ़ छिपे हुए पर अंक
यही pre-training है: कोई task दिमाग़ में नहीं, बस इतना कि "the cat" और
"on the mat" के बीच आम तौर पर क्या आता है। फिर हर task के लिए एक छोटा head
लगाओ और पूरे मॉडल को थोड़ा और train करो। वही fine-tuning है। एक ही library,
हर task के लिए अलग main()।
| शब्द | मतलब | Programmer की उपमा |
|---|---|---|
| MLM | Masked language model: tokens छिपाओ, फिर उनका अनुमान लगाओ | रिक्त स्थान भरो |
| Bidirectional | हर token अपने बाएँ और दाएँ, दोनों पर attend करता है | कोई causal mask नहीं। बस mask=None |
| Pre-train / fine-tune | भाषा एक बार सीखो; फिर head जोड़कर हर task के लिए हर parameter train करो | एक साझा library, हर task के लिए अलग main() |
| [CLS] | शुरू में जोड़ा गया एक अतिरिक्त token, जिसका output पूरे input का प्रतिनिधि है | पूरे sequence पर एक reduce(), एक struct में |
यह GPT-1 और ELMo से आगे क्यों निकला
एक सामान्य language model tokens 1–4 से token 5 का अनुमान लगाता है, फिर 1–5
से 6 का। हर token सिर्फ़ अपना बायाँ देखता है। GPT-1 बिलकुल यही था। Text
generate करने के लिए ठीक, लेकिन "the bank of the river" में "bank" का सबसे
अच्छा सुराग़ दाईं तरफ़ है। तो हर token को दोनों तरफ़ देखने क्यों न
दें? क्योंकि वह नक़ल कर लेता है: stacked encoder में layer 2 का token 5,
layer 1 का token 6 पढ़ता है, जो token 5 को पहले ही पढ़ चुका है। Paper कहता है
कि हर शब्द "अप्रत्यक्ष रूप से ख़ुद को देख सकता है"। ELMo ने इससे बचने के लिए
एक left-to-right LSTM और एक right-to-left LSTM को अंत में जोड़ा — एक "shallow
concatenation"। BERT की एक ही तरकीब: अगर token छिपा है, तो लीक होने
को कुछ है ही नहीं। उसे [MASK] से बदल दो, सबको हर जगह देखने दो,
और अंक पाने का इकलौता रास्ता है दोनों तरफ़ से अनुमान लगाना। यह रिक्त-स्थान
वाली बात कोई प्यारी सी उपमा भर नहीं है। यही गहरी bidirectionality को जायज़
बनाती है।
वह एक सूत्र
x मूल tokens हैं, x̃ वही sequence जिसका 15% बिगाड़ा गया है, और M बिगाड़ी गई positions का समूह। इसे चरणों में पढ़िए:
| चरण | Shape | क्या करता है |
|---|---|---|
x | (L,) | एक sequence के token ids। Paper में L = 512, मेरी script में 17 |
x̃ = corrupt(x) | (L,) | 15% positions चुनो। उनमें से 80% [MASK], 10% कोई random token, 10% अछूते |
encoder(x̃) | (L, d) | Attention page वाला attention stack, mask=None के साथ। हर row हर column देखती है |
head | (L, V) | Vocabulary logits के लिए एक linear layer: "यहाँ कौन सा token था?" |
i ∈ M पर −log p | scalar | Cross-entropy, पर सिर्फ़ बिगाड़ी गई positions पर। बाक़ी 85% context हैं, सवाल नहीं |
Masking की विधि: 15%, फिर 80/10/10
अजीब हिस्सा 80/10/10 है। हर चुने हुए token को [MASK] से क्यों न
बदलें? क्योंकि fine-tune करते समय [MASK] कभी आता ही नहीं।
10% random और 10% ज्यों का त्यों रखने का मतलब है कि model को कभी-कभी किसी
दिखते हुए token पर अंक मिलते हैं, इसलिए वह अपने input पर पूरा भरोसा
कभी नहीं कर सकता। इनके बिना train करो और दिखते हुए token पर accuracy 99.3%
से गिरकर 53.0% हो जाती है: break-it तालिका की एक row, और paper के appendix
का एक ablation।
NumPy में यह विधि, एक वाक्य और फिर दस हज़ार:
original : [CLS] d D e E g G h H a A b B g G h H
corrupted: [CLS] d D e _ g G _ H a A b B g G h _
labels : . . . . E . . h . . . . . . . . H
kind : . . . . M . . M . . . . . . . . M
(M = [MASK] R = random token K = kept as-is . = no loss)
over 10000 sentences (160000 real tokens):
[MASK] 12.0% (recipe says 12.0%)
random 1.5% (recipe says 1.5%)
kept 1.5% (recipe says 1.5%)
picked 15.0% (recipe says 15.0%) ✓
मेरा खिलौना corpus: एक "वाक्य" यानी आठ कोष्ठक-जोड़े, एक opener
(a–h) और उसके बाद उसी का closer
(A–H), openers random चुने गए। यानी closer का
अनुमान उसके बाएँ से लग जाता है, पर opener का सिर्फ़ उसके
दाएँ से। यह जानबूझकर है: आधे रिक्त स्थानों को दायाँ context चाहिए।
Mask हटाओ और हर token दोनों तरफ़ देखेगा
Attention page का scaled_dot_product_attention, वही input, एक
argument बदला हुआ। Row = एक token; column = वह दूसरे को कितना पढ़ता है।
no mask (BERT) -- each row = what that token can see:
b [0.79 0.01 0.04 0.04 0.11] sees 5 of 5
B [0.02 0.86 0.04 0.03 0.05] sees 5 of 5
e [0.20 0.10 0.28 0.21 0.22] sees 5 of 5 ← दाईं ओर का E पढ़ा
E [0.20 0.07 0.25 0.27 0.21] sees 5 of 5
a [0.39 0.09 0.17 0.13 0.23] sees 5 of 5
causal mask (GPT) -- each row = what that token can see:
b [1.00 0.00 0.00 0.00 0.00] sees 1 of 5
B [0.03 0.97 0.00 0.00 0.00] sees 2 of 5
e [0.34 0.17 0.49 0.00 0.00] sees 3 of 5 ← E का पता नहीं
E [0.26 0.09 0.32 0.34 0.00] sees 4 of 5
a [0.39 0.09 0.17 0.13 0.23] sees 5 of 5
mask=None यानी BERT;
mask=causal_mask(n) यानी GPT। आख़िरी row दोनों में एक जैसी है:
आख़िरी token हमेशा सब कुछ देखता है, इसीलिए causal model generation के लिए
ठीक है। BERT चाहता है कि यह नज़र हर token के पास हो।
इसे train करो: loss गिरता है, रिक्त स्थान भरते हैं
Encoder यानी attention page के दो blocks, PyTorch में, 32 चौड़ा, दो heads, 27k parameters। BERT-base यानी बारह blocks, 768 चौड़ा, बारह heads, 110M parameters, BooksCorpus और Wikipedia पर दस लाख steps। वही आकार, छोटे नंबर। Laptop CPU पर छह सौ steps:
step 1 loss 3.083 masked-token accuracy 5.3%
step 200 loss 0.289 masked-token accuracy 89.4%
step 400 loss 0.429 masked-token accuracy 89.6%
step 600 loss 0.407 masked-token accuracy 89.4% ✓
partner visible and intact : 99.9% (4172 targets)
partner hidden or corrupted: 18.3% (613 targets, chance = 12.5%)
89% क्यों, 100% क्यों नहीं? आख़िरी दो लाइनें बताती हैं। Partner दिख रहा हो: लगभग पूर्ण। जोड़े के दोनों हिस्से चुन लिए जाएँ: अनुमान लगाने को कुछ बचा ही नहीं। वह 11% विधि है, model नहीं।
एक held-out वाक्य, model द्वारा भरे गए रिक्त स्थान:
original : [CLS] a A e E h H a A g G g G c C f F
input : [CLS] a A e E h H a _ g G g G c b f _
predicted: [CLS] a A e E h H a A g G g G c C f F ✓✓✓
दो रिक्त स्थान भर गए। और c के बाद वाला b एक random
replacement था, रिक्त स्थान नहीं; model ने उसे भी सुधार दिया। यही 10% random
वाला नियम है।
Mask रहने दो और इसे नाकाम होते देखो
अब paper का असली तर्क। वही model, वही data stream, वही seed, वही 600 steps।
बदला सिर्फ़ causal=True: attention page वाला lower-triangular
mask, वापस लगा दिया गया।
bidirectional causal
all masked tokens 89.4% 55.5%
closers (clue on the LEFT) 89.6% 89.7% ✓
openers (clue on the RIGHT) 89.3% 20.4%
chance = 12.5%
इस बँटवारे को देखिए। Causal model कोष्ठक उतने ही अच्छे से बंद करता है जितना BERT; opener उसके बाईं ओर है। पर वह opener का अनुमान नहीं लगा सकता: इकलौता सबूत उसके दाएँ वाला closer है, और mask उसे छिपा देता है। 20%, यानी random से ज़रा ही ऊपर। आधी भाषा का सुराग़ दाईं तरफ़ होता है। Paper का पूरा दावा उसी एक row में है।
[CLS], NSP, और हर task के लिए अलग main()
BERT हर input के आगे एक अतिरिक्त token, [CLS], जोड़ देता है।
कोई अर्थ नहीं; बस एक जगह। Stack के ऊपर उसका output ही "वह वाक्य" है:
encoder output (batch, 17, 32), row 0 यानी (batch, 32), एक linear layer से
(batch, 2)। पूरा classification head बस इतना ही है।
Paper का दूसरा pre-training task, next sentence prediction,
उस जगह को काम देता है। वाक्य A और B लो; आधी बार B सचमुच A के बाद आया था,
आधी बार वह random है। [CLS] A [SEP] B [SEP] के साथ एक सीखा हुआ
segment embedding दो, और [CLS] के output को IsNext या
NotNext कहना सिखाओ। §5.1 का ablation कहता है कि इसे हटाने से QNLI,
MNLI और SQuAD को नुक़सान होता है। एक साल बाद RoBERTa ने NSP के बिना, ज़्यादा
data पर train करके BERT की बराबरी की या उसे पीछे छोड़ा, तो field ने इसे छोड़
दिया। मैंने इसे implement नहीं किया, और उसकी क़ीमत दिखती है:
pre-training में [CLS] ने कुछ किया ही नहीं।
Fine-tuning इसे ठीक करता है, पर तभी जब gradients encoder तक पहुँचें। खिलौना task, एक छोटा सा CoLA: क्या यह वाक्य व्याकरण के हिसाब से सही है? आधों में एक closer को ग़लत closer से बदल दिया गया है।
300 steps of fine-tuning trainable params accuracy
head only, encoder frozen 66 51.8%
everything (the paper) 27188 99.2%
everything, NO pre-training 27188 50.1%
chance = 50.0%
तीन rows, और paper के दोनों दावे। Encoder freeze करो: बस संयोग जितना। सब कुछ fine-tune करो: 300 steps में हल। किसी random encoder से उन्हीं 300 steps के लिए fine-tune करो: फिर संयोग जितना। Pre-training fine-tuning को सस्ता बनाती है; सब कुछ fine-tune करना pre-training को काम का बनाता है। Paper दोनों करता है, पूरे 110M parameters पर, 2e-5 से 5e-5 पर, दो से चार epochs। मेरे खिलौने में frozen head को कुछ नहीं मिलता; paper के अपने NER वाले feature-based प्रयोग में वह लगभग पूरा रास्ता तय कर लेता है, क्योंकि NSP के साथ बारह layers के असली features मेरे 32 नंबरों से बेहतर हैं।
The code
NumPy for the mechanism: the masking recipe and the attention masks. A tiny PyTorch model, CPU only, trains in under 30 seconds for the one part that needs gradients. Every function under 20 lines. The new parts are below; the NumPy attention is the attention page's, copied.
Download the full script — about 200 lines of mechanism plus six runnable demos. Then:
python3 bert_from_scratch.py
def mask_batch(ids, rng, p_pick=0.15, p_mask=0.8, p_random=0.1):
corrupted, labels = ids.copy(), np.full_like(ids, -1)
kind = np.zeros_like(ids) # 0 untouched, 1 [MASK], 2 random, 3 kept
picked = (rng.random(ids.shape) < p_pick) & (ids != CLS) # never pick [CLS]
labels[picked] = ids[picked] # the answer key
roll = rng.random(ids.shape)
to_mask = picked & (roll < p_mask)
to_random = picked & (roll >= p_mask) & (roll < p_mask + p_random)
to_keep = picked & ~to_mask & ~to_random
corrupted[to_mask] = MASK
corrupted[to_random] = rng.integers(OPEN0, VOCAB, int(to_random.sum()))
kind[to_mask], kind[to_random], kind[to_keep] = 1, 2, 3
return corrupted, labels, kind
class TinyBERT(nn.Module):
def __init__(self, causal=False, d=32, heads=2, layers=2):
super().__init__()
self.tok = nn.Embedding(VOCAB, d)
self.pos = nn.Embedding(L, d) # BERT learns positions, not sines
self.blocks = nn.ModuleList(Block(d, heads) for _ in range(layers))
self.mlm_head = nn.Linear(d, VOCAB) # "which token was here?"
# THE SWITCH. None = every token sees both sides (BERT).
# -inf above the diagonal = every token sees only its left (GPT).
self.attn_mask = (torch.triu(torch.full((L, L), float("-inf")), 1)
if causal else None)
def encode(self, ids): # (batch, L) -> (batch, L, d)
x = self.tok(ids) + self.pos(torch.arange(L))
for block in self.blocks:
x = block(x, self.attn_mask)
return x
def forward(self, ids): # (batch, L) -> (batch, L, VOCAB)
return self.mlm_head(self.encode(ids))
def mlm_loss(logits, labels):
labels = torch.from_numpy(labels)
if int((labels >= 0).sum()) == 0: # nothing picked -> nothing to learn
return logits.sum() * 0.0
return F.cross_entropy(logits.reshape(-1, VOCAB), labels.reshape(-1),
ignore_index=-1)
Now break it
This is the part that actually makes it stick. Reading won't do it. Every row here is one edit and one number the script will print back.
| Break this | Watch for |
|---|---|
In demo_3_train_bert, change TinyBERT(causal=False) to causal=True | Masked-token accuracy drops from 89.4% to 55.5%. Openers, whose only clue is on the right, fall to 20.4%. Right context is invisible. |
Change the mask_batch defaults to p_mask=1.0, p_random=0.0 | Accuracy at kept-as-is positions goes from 99.3% to 53.0%. The model has never been marked on a visible token; fine-tuning will show it nothing else. |
Change the default to p_pick=0.5 | On its own 50%-masked data it scores 64.2%: only 55% of blanks still have their partner visible. The rule is learned; the task became unanswerable. |
Change the default to p_pick=0.0 | Loss prints 0.000 from step 1 and never moves. No blanks, no loss, no gradient. Accuracy 8.3%, below chance. |
Freeze the encoder: p.requires_grad_(False) on every encoder parameter | 66 trainable parameters, 51.8% on the grammar task. Chance. The paper fine-tunes all 110M for a reason. |
Skip pre-training: Classifier(TinyBERT()) instead of Classifier(copy.deepcopy(bert)) | Same 300 steps, all 27188 parameters trainable, 50.1%. Fine-tuning a random encoder is training from scratch, slowly. |
On the maths anxiety
New maths on this page, in full: cross-entropy is −log of the
probability the model put on the right answer, averaged over the blanks. A
log so a small probability becomes a big penalty. That's it. Everything else
is the attention page's maths, and you cleared that.
What actually bites in practice is not maths: WordPiece splitting a word you care about into three pieces, the 512-token limit, which layer to take features from, and whether three epochs of fine-tuning silently overfit. Those are engineering, and they're what an interview will actually probe.
Where to go next
This page gave you one thing: BERT as a cloze test, with a model you can break. That's deliberately narrow. These cover what I skipped.
-
BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding
§3 is the method and is short. §5.1 is the ablation that proves what my demo 4 shows, on real data. The appendix has the 80/10/10 ablation. Everything else is results tables you can now read as "and it worked".
-
The Illustrated BERT, ELMo, and co.
The lineage I compressed into one section: ULM-FiT, ELMo, the OpenAI transformer, and why each was a step toward BERT. Also the best picture anywhere of what
[CLS]and sentence-pair inputs look like. -
A Visual Guide to Using BERT for the First Time
Real DistilBERT, real sentiment data, frozen features and a logistic regression. The feature-based approach my frozen row scored 51.8% on, done on a model whose
[CLS]actually learned something.