Special Tokens › Code Implementation

Michael BrenndoerferApril 18, 202511 min read
Language AI HandbookAll chapters
Part 5 of 67Subword Tokenization
Chapter 34Special Tokens › Code Implementation
Reading section 8 of 12 · Code Implementation · 11 minSection 8 of 12 · 11 min

Book contents

Section 8 of 12 · 11 min read

Special Tokens › 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's work through a complete implementation demonstrating special token behavior using the transformers library. The goal is to make every concept from the preceding sections concrete and verifiable.

Setup and Imports

We start by loading a BERT tokenizer and model to inspect how special tokens are handled:

In[5]:
Code
from transformers import BertTokenizer

# Load BERT base tokenizer
tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")

Examining Special Token IDs

Every special token has a reserved ID in the vocabulary. Inspecting these IDs confirms the conventions discussed in the theory sections:

In[6]:
Code
# Inspect special token IDs
special_token_info = {
    "[CLS]": tokenizer.cls_token_id,
    "[SEP]": tokenizer.sep_token_id,
    "[PAD]": tokenizer.pad_token_id,
    "[MASK]": tokenizer.mask_token_id,
    "[UNK]": tokenizer.unk_token_id,
}
Out[7]:
Console
Special Token IDs:
  [CLS]    -> ID 101
  [SEP]    -> ID 102
  [PAD]    -> ID 0
  [MASK]   -> ID 103
  [UNK]    -> ID 100

The [PAD] token receives ID 0, which is a common convention allowing zero-initialized padding tensors. The other tokens occupy specific reserved slots at the start of the BERT vocabulary. [CLS] (101), [SEP] (102), and [MASK] (103) are assigned consecutive IDs. These IDs mark them as core structural tokens.

Single Sentence Tokenization

Now let's see how special tokens are automatically inserted during tokenization. The add_special_tokens=True parameter (the default) instructs the tokenizer to automatically wrap the input with the appropriate structural tokens:

In[8]:
Code
sentence = "The cat sat on the mat."

# Tokenize with special tokens
encoded = tokenizer(sentence, return_tensors="pt", return_token_type_ids=True)

# Decode each token ID back to its string
token_ids = encoded["input_ids"][0].tolist()
tokens = tokenizer.convert_ids_to_tokens(token_ids)
token_types = encoded["token_type_ids"][0].tolist()
attention_mask = encoded["attention_mask"][0].tolist()
Out[9]:
Console
Single sentence tokenization:
Token        ID    Type  Mask
------------------------------
[CLS]          101     0    1
the           1996     0    1
cat           4937     0    1
sat           2938     0    1
on            2006     0    1
the           1996     0    1
mat          13523     0    1
.             1012     0    1
[SEP]          102     0    1

The [CLS] token appears at position 0, [SEP] at the end, and all tokens receive type ID 0 and attention mask value 1, since there is no padding. The automatic insertion of [CLS] and [SEP] is handled entirely by the tokenizer: you pass a plain string and receive a properly framed sequence. This automation is convenient but can hide the special tokens from view, which is why it's worth examining the output explicitly at least once to understand what the tokenizer is doing.

Sentence Pair Tokenization

For tasks like Natural Language Inference, BERT takes two sentences as input. The tokenizer handles the two-sentence format automatically, inserting the correct special tokens and generating the type IDs:

In[10]:
Code
sentence_a = "The cat sat on the mat."
sentence_b = "A cat was resting."

# Tokenize sentence pair
encoded_pair = tokenizer(
    sentence_a,
    sentence_b,
    return_tensors="pt",
    return_token_type_ids=True,
    padding=True,
)

pair_ids = encoded_pair["input_ids"][0].tolist()
pair_tokens = tokenizer.convert_ids_to_tokens(pair_ids)
pair_types = encoded_pair["token_type_ids"][0].tolist()
pair_mask = encoded_pair["attention_mask"][0].tolist()
Out[11]:
Console
Sentence pair tokenization:
Token        ID    Type  Mask
------------------------------
[CLS]          101     0    1
the           1996     0    1
cat           4937     0    1
sat           2938     0    1
on            2006     0    1
the           1996     0    1
mat          13523     0    1
.             1012     0    1
[SEP]          102     0    1
a             1037     1    1
cat           4937     1    1
was           2001     1    1
resting       8345     1    1
.             1012     1    1
[SEP]          102     1    1

Notice how sentence A tokens receive type ID 0 and sentence B tokens receive type ID 1. The second [SEP] token, which closes sentence B, also receives type ID 1. The [CLS] token and the first [SEP] both receive type ID 0, since they are part of the "sentence A" region. This is the standard BERT convention: [CLS] and the first [SEP] are attributed to sentence A, while sentence B's closing [SEP] is attributed to sentence B.

Padding in Batches

Padding becomes visible when we process sequences of different lengths together. This demonstrates why the attention mask is required for correctness:

In[12]:
Code
sentences = [
    "Short sentence.",
    "This is a slightly longer sentence with more words.",
    "A medium length sentence here.",
]

batch = tokenizer(
    sentences,
    return_tensors="pt",
    padding=True,
    truncation=True,
    max_length=20,
)

batch_tokens = [
    tokenizer.convert_ids_to_tokens(ids.tolist()) for ids in batch["input_ids"]
]
Out[13]:
Console
Padded batch (showing token IDs and attention masks):

Sentence 1:
  Tokens: [CLS] short sentence . [SEP] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD]
  Mask:   1 1 1 1 1 0 0 0 0 0 0 0

Sentence 2:
  Tokens: [CLS] this is a slightly longer sentence with more words . [SEP]
  Mask:   1 1 1 1 1 1 1 1 1 1 1 1

Sentence 3:
  Tokens: [CLS] a medium length sentence here . [SEP] [PAD] [PAD] [PAD] [PAD]
  Mask:   1 1 1 1 1 1 1 1 0 0 0 0

Shorter sequences are padded with [PAD] tokens to match the length of the longest sequence in the batch. The attention mask marks these positions with 0, and the model will exclude them from its attention computation. The padding ensures all sequences in the batch have the same tensor dimensions, enabling GPU matrix operations. Without the attention mask, the padding tokens would contaminate the real token representations.

The heatmap below shows the attention mask for the padded batch, making the active versus masked positions immediately visible:

Out[14]:
Visualization
Heatmap showing three rows with real tokens highlighted in blue and padding positions in gray.
Attention mask heatmap for a padded batch of three sentences. Blue cells (value 1) indicate real tokens that participate in attention; gray cells (value 0) indicate padding positions that are masked out. The shortest sentence has the most masked positions on the right, and all three sequences share the same tensor width due to padding.

The MLM Masking Procedure

Let's reproduce BERT's masking strategy to see exactly how [MASK] tokens are introduced during pre-training. Understanding this procedure at the code level reinforces the 80/10/10 design described in the theory section:

In[15]:
Code
def apply_mlm_masking(token_ids, tokenizer, mask_prob=0.15, seed=42):
    """Apply BERT-style MLM masking: 80% [MASK], 10% random, 10% unchanged."""
    rng = np.random.default_rng(seed)
    labels = [-100] * len(token_ids)  # -100 means "ignore this position"
    masked_ids = token_ids.copy()

    # Special token positions that should never be masked
    special_ids = {
        tokenizer.cls_token_id,
        tokenizer.sep_token_id,
        tokenizer.pad_token_id,
    }

    for i, token_id in enumerate(token_ids):
        if token_id in special_ids:
            continue  # Never mask special tokens

        if rng.random() < mask_prob:
            labels[i] = token_id  # Store original for loss computation
            r = rng.random()
            if r < 0.80:
                masked_ids[i] = tokenizer.mask_token_id  # Replace with [MASK]
            elif r < 0.90:
                masked_ids[i] = int(
                    rng.integers(100, len(tokenizer))
                )  # Random token
            # else: keep original (remaining 10%)

    return masked_ids, labels


sentence = "The quick brown fox jumps over the lazy dog."
token_ids = tokenizer.encode(sentence)
tokens_before = tokenizer.convert_ids_to_tokens(token_ids)

masked_ids, labels = apply_mlm_masking(token_ids, tokenizer, seed=7)
tokens_after = tokenizer.convert_ids_to_tokens(masked_ids)
Out[16]:
Console
MLM masking demonstration:
Position Before       After        Label       
------------------------------------------------
       0 [CLS]        [CLS]        -           
       1 the          the          -           
       2 quick        quick        -           
       3 brown        brown        -           
       4 fox          fox          -           
       5 jumps        jumps        -           
       6 over         over         -           
       7 the          max          the          <-- masked
       8 lazy         lazy         -           
       9 dog          dog          -           
      10 .            .            -           
      11 [SEP]        [SEP]        -

The masking procedure shows the 80/10/10 split in action: some positions receive the explicit [MASK] token, some are replaced with a random vocabulary token, and some remain unchanged. In all three cases, the model is trained to predict the original token at the label position (stored as labels[i]). Positions with label = -100 are excluded from the loss computation: the model is only evaluated on positions that were selected for masking, not on all positions simultaneously.

The special token positions ([CLS] and [SEP]) are explicitly excluded from masking. This exclusion is important for two reasons. First, the model needs reliable structural tokens: if [CLS] were sometimes replaced with a random token, the model could not reliably use the [CLS] position for classification. Second, the model should not need to predict [CLS] and [SEP] from context, since they carry structural rather than semantic information and their correct placement is determined by the tokenizer, not the text content.

To see the 80/10/10 distribution at scale, we can apply the masking strategy over many events and count how the selected positions break down:

Out[17]:
Visualization
Bar chart showing proportions of [MASK], random, and unchanged token replacements in MLM masking.
Distribution of the three MLM masking strategies across 10,000 simulated masking events. Approximately 80% of selected positions receive the explicit [MASK] token, 10% are replaced with a random vocabulary token, and 10% remain unchanged. This 80/10/10 split balances the MLM learning signal against the pre-training/fine-tuning mismatch.

Adding Custom Special Tokens

Let's add domain-marker tokens to signal the input language for a multilingual scenario. This demonstrates the full workflow of extending a tokenizer and resizing the embedding layer:

In[18]:
Code
from transformers import BertModel

# Add a custom special token for domain marking
custom_tokens = ["[ENGLISH]", "[FRENCH]", "[SPANISH]"]
num_added = tokenizer.add_special_tokens(
    {"additional_special_tokens": custom_tokens}
)

# Resize the model embedding layer
model = BertModel.from_pretrained("bert-base-uncased")
model.resize_token_embeddings(len(tokenizer))

# Verify the new tokens got IDs
new_token_ids = {
    tok: tokenizer.convert_tokens_to_ids(tok) for tok in custom_tokens
}
Out[19]:
Console
Added 3 new special tokens to vocabulary
New vocabulary size: 30525

Custom special token IDs:
  [ENGLISH]    -> ID 30522
  [FRENCH]     -> ID 30523
  [SPANISH]    -> ID 30524

Tokenized '[ENGLISH] The cat sat on the mat.':
  ['[ENGLISH]', 'the', 'cat', 'sat', 'on', 'the', 'mat', '.']

The custom tokens are treated as atomic units: they will not be split by the WordPiece algorithm, and their IDs point to new rows in the embedding table that can be trained during fine-tuning. The resize_token_embeddings call extends the model's input embedding matrix and output projection layer to accommodate the new vocabulary entries. These new rows are randomly initialized by default, so fine-tuning on sufficient data is required for them to learn meaningful representations.

Key Parameters Summary

The key parameters for special token handling in HuggingFace are:

  • add_special_tokens: When set to True in tokenizer(...), automatically inserts [CLS], [SEP], and other required tokens. Defaults to True. Set to False only when you need manual control over special token placement.
  • return_token_type_ids: When set to True, returns the segment ID tensor alongside input_ids. Required for BERT sentence-pair tasks.
  • return_attention_mask: When set to True, returns the attention mask tensor. Defaults to True in most contexts. Always inspect this tensor when debugging unexpected model behavior.
  • padding: Controls how sequences in a batch are padded. True pads to the longest sequence in the batch; "max_length" pads to a fixed length specified by max_length.
  • truncation: When set to True with max_length, sequences exceeding the limit are cut to fit. For sentence pairs, truncation applies to the longer of the two sentences by default.