BERT: Bidirectional Encoder Representations from Transformers
We will implement a simple BERT model from scratch and train it on a small dataset.
Data Preprocessing
Like GPT, BERT trains on raw, unlabeled text — no human-written pairs needed. We reuse the same cached Arabic Wikipedia corpus (1,500 articles) from the GPT experiment, but shape it very differently, since Masked Language Modeling is a fundamentally different task from next-token prediction.
Tokenization: AraBERT’s pretrained WordPiece tokenizer AraBERT’s tokenizer was trained specifically for Arabic. It also comes with [CLS], [SEP], [MASK], and [PAD] already built into its 64,000-token vocabulary.
Shaping the data: masked sequences, not shifted ones. FOr each sequence, we do the following:
80% of the time → replace with MASK_ID
10% of the time → replace with a random token id from the vocab
10% of the time → leave it exactly as it was (no change to input_ids)
Example:
input_ids: [CLS, A, [MASK], C, D, E, X, G, H, SEP] ← B became [MASK], F became a random token X
labels: [-100, -100, B, -100, -100, -100, F, -100, -100, -100] ← original B and F saved here
from torch.utils.data import DataLoader, random_splitfrom bert_data_pipeline import train_dataset, BATCH_SIZEval_size =max(1, int(0.1*len(train_dataset)))train_size =len(train_dataset) - val_sizetrain_subset, val_subset = random_split( train_dataset, [train_size, val_size], generator=torch.Generator().manual_seed(42),)# Your dataset generates fresh masks every time an item is fetched.# Save validation examples once to keep their masks fixed.random_state = random.getstate()random.seed(42)val_examples = [val_subset[i] for i inrange(len(val_subset))]random.setstate(random_state)train_loader = DataLoader( train_subset, batch_size=BATCH_SIZE, shuffle=True,)val_loader = DataLoader( val_examples, batch_size=BATCH_SIZE, shuffle=False,)
THe above is containing these:
input_ids — the actual token ids fed into the embedding layer. Each row is [CLS] token token [MASK] token ... [SEP]
labels — same shape, but almost entirely -100. Only at the positions that were selected for masking does labels hold the original, uncorrupted token id — the answer the model is being trained to recover.
Model Architecture
BERT is your existing Encoder from the Attention notebook, almost unchanged — same self-attention, residuals, LayerNorm, feed-forward blocks, stacked N times. There are two main differences from the GPT-1 model:
1- No causal mask. The GPT’s causal_mask blocked each position from seeing anything ahead of it. BERT’s self-attention gets no such restriction — every position attends to every other position, both directions, every layer.
2- A new prediction head for MLM. Instead of GPT’s output_layer predicting “what’s the next token,” BERT’s output layer predicts “what was the original token at this position,” but only evaluated at the masked positions.
The flow of data through the BERT model is as follows:
input_ids → embeddings → stack of encoder blocks (bidirectional self-attention) → linear layer projecting to vocab size at every position → loss computed only where labels != -100
Show Code
class BERTEmbedding(nn.Module):def__init__(self, VOCAB_SIZE, SEQ_LEN, d_model):super().__init__()self.token_embedding = nn.Embedding(VOCAB_SIZE, d_model)self.position_embedding = nn.Embedding(SEQ_LEN, d_model)self.segment_embedding = nn.Embedding(2, d_model) # to which segment (A/B) the sentance belongsdef forward(self, input_ids, segment_ids): batch_size, seq_len = input_ids.shape positions = torch.arange(seq_len, device=input_ids.device) # [0, 1, 2, ..., 127]# This will broadcasts the above row across all 32 sequences in the batch positions = positions.unsqueeze(0).expand(batch_size, seq_len) token_embeds =self.token_embedding(input_ids) # (batch_size, seq_len, d_model) position_emb =self.position_embedding(positions) # (batch_size, seq_len, d_model) segment_emb =self.segment_embedding(segment_ids) # (batch_size, seq_len, d_model)return token_embeds + position_emb + segment_emb
Below is the class definition for a single BERT encoder layer, in which the self-attention is bidirectional (no causal mask) followed by a feed-forward block, with residual connections and layer normalization.
THen we stack N of these encoder layers to form the full BERT encoder block. The final output is a sequence of hidden states, one for each input token.
Show Code
class BERTEncoder(nn.Module):def__init__(self, d_model, num_heads, ff_hidden, num_layers):super().__init__()#initialize the encoder layers then wrap them in a nn.ModuleList for N layersself.encoder_layers = nn.ModuleList([BERTEncoderLayer(d_model, num_heads, ff_hidden) for _ inrange(num_layers)])def forward(self, x, attention_mask=None):for layer inself.encoder_layers: x = layer(x, attention_mask=attention_mask)return x
Finally, this class is wrapping the embedding layer, the encoder stack, and the output layer (The MLM head) into a single model. The forward pass returns the logits for each position in the sequence, which are then used to compute the loss against the labels.
Comments