Deep Learning · Module 19 of 26 · 8 concepts · about 18 min

Embeddings & Tokenization

Turning discrete symbols into vectors a neural network can learn from.

At work: Turning words, products and users into numbers, the basis of semantic search, recommendations and chatbots.

Subword tokenization and embedding tables

What it is

A tokenizer maps text to integer token IDs, commonly with BPE or WordPiece subwords rather than whole words. Subwords make a finite vocabulary capable of representing rare names, inflections, code, and new compounds by breaking them into reusable pieces. An embedding table maps each ID to a learned vector; adding position information yields the ordered sequence of vectors presented to a Transformer. The embedding table is trained like any other parameter matrix: only rows for tokens appearing in a batch receive direct gradient updates.

Why it matters

Tokenization fixes the vocabulary, sequence length, cost, and even what text a model can express. Embeddings give tokens with similar training contexts an opportunity to become nearby vectors, but proximity is a learned association, not a guaranteed human semantic truth. A tokenizer mismatch at inference is therefore catastrophic: the same text becomes different IDs, and the model is asked to interpret a language it was not trained to read.

In the real world: How a chatbot turns a customer's message into numbers it can work with.

Visual example

Deep learning

Text becomes token IDs, then learned vectors with position information.

Text to model input

  1. text"unbelievable!"
  2. tokenizer[un, believable, !] → IDs
  3. embedding tableone vector per ID
  4. add positionordered token vectors for the model

The model never receives characters or words directly—only vectors.

import torch
import torch.nn as nn

ids = torch.tensor([[1045, 2293, 2204, 4157]])   # (batch=1, seq=4)
embed = nn.Embedding(num_embeddings=30_522, embedding_dim=768)
vectors = embed(ids)
vectors.shape          # torch.Size([1, 4, 768])
embed.weight.shape     # torch.Size([30522, 768]) - one row per vocabulary entry

Counting words to estimate context length. Models consume tokenizer-specific tokens, not words or characters; always use the tokenizer for the model you will run.

What a token actually is

What it is

A token is the atomic unit a model operates on: one entry in a fixed, finite vocabulary, referred to everywhere downstream by its integer index. The model has no access to the string a token came from. Its entire knowledge of that token is one row of the embedding matrix, learned from every context in which that index appeared during training. A sequence, to the model, is a list of integers in [0, vocab_size) — nothing more.

Why it matters

Almost every confusing behaviour at the text boundary follows from this. A model cannot spell a word it only ever saw as one token, because it never saw the letters; it cannot count characters reliably for the same reason; and a leading space is a *different token* from the same word without one, which is why prompts sometimes behave differently for no visible reason. Once you accept that the model's world is integers, these stop being mysteries and become predictable consequences.

In the real world: AI APIs bill per token, so understanding tokens directly affects cost.

Visual example

The text boundary

The same sentence at four levels of description. Only the last row ever reaches the network.

What you wrote

(a string)

What the model gets

(an integer tensor)

"the cat sat"
representationcount
charst·h·e· ·c·a·t· ·s·a·t11
wordsthe | cat | sat3
tokensthe | ▁cat | ▁sat3
IDs[1, 2, 3]3

Cost, context limits and billing are all counted in row 3, not row 0 or row 1

Why this matters

Every practical quantity — sequence length, context window, price per request, throughput — is denominated in tokens. Estimating any of them from a character or word count is guessing at a conversion rate that varies by language and by tokenizer.

Common misconception

Wrong idea: the model reads text and we encode it for convenience.

Correct idea: the model only reads integers. The text never enters the network at all; the tokenizer is a lossy, irreversible-in-spirit translation layer that defines what the model is capable of perceiving.

# Every model input is ultimately this: a tensor of vocabulary indices.
import torch

vocab = {'<pad>': 0, 'the': 1, 'cat': 2, 'sat': 3, '<unk>': 4}
text = ['the', 'cat', 'sat']
ids = torch.tensor([[vocab.get(t, vocab['<unk>']) for t in text]])
ids                    # tensor([[1, 2, 3]])
ids.dtype              # torch.int64 - indices, never floats

# The model sees this. Not 'the cat sat'.

Asking a model to reverse a string, count letters, or do arithmetic on long numbers and reading failure as a reasoning deficit. Often it is a tokenization artifact: the characters or digits it is being asked to manipulate were never individually visible to it.

Word, subword and character tokenization

What it is

Word-level tokenization assigns one ID per whole word, which needs a huge vocabulary and still fails on anything unseen. Character-level tokenization needs a tiny vocabulary and never fails, but makes sequences several times longer and forces the model to rediscover words from letters. Subword tokenization — BPE, WordPiece, Unigram — sits between them: it learns a vocabulary of frequent character sequences, keeps common words whole, and splits rare ones into pieces it already knows.

Why it matters

Subword tokenization solves one specific problem, and naming it precisely is worth more than the taxonomy: out-of-vocabulary tokens. A word-level model meeting dexamethasone for the first time has exactly one option — emit <unk>, discarding the word entirely — and any text containing it becomes partly unreadable. A subword model splits it into dexa, meth, asone and produces a composed representation that is imperfect but genuinely informative. That is why subword won: it makes a *finite* vocabulary able to represent *unbounded* text.

In the real world: Subwords let a model handle new product names and typos it never saw.

Visual example

Granularity

Three granularities, and the one quantity each one makes worse.

Sequences too long

(attention cost is quadratic)

Unseen words lost

(becomes <unk>)

Tokenizing "unbelievable"
piecesvocab sizeOOV risk
characteru·n·b·e·l·i·e·v·a·b·l·e (12)~100none
subwordun | believ | able (3)~32knone
wordunbelievable (1)~500khigh

Subword is the only row with no column in the danger zone

Why this matters

This table is the whole argument for why modern models are subword. It is not a subtle aesthetic preference — the other two rows each have one entry that is disqualifying at scale.

  1. 1Start from charactersAlways representable, but sequences are hopelessly long.
  2. 2Merge frequent pairsBPE repeatedly merges the commonest adjacent pair into one new token.
  3. 3Stop at a budgetStop merging at the target vocabulary size. Common words end up whole; rare words stay split.
# The out-of-vocabulary problem, made concrete.
word_vocab = {'the', 'cat', 'sat', 'running'}

def word_tokenize(word):
    return [word] if word in word_vocab else ['<unk>']

def subword_tokenize(word):
    # A real BPE merges learned pairs; the point is that it always succeeds.
    pieces = {'run': 'run', 'ning': 'ning', 'ner': 'ner'}
    if word == 'runner':
        return ['run', 'ner']
    return [pieces.get(word, word)]

word_tokenize('runner')        # ['<unk>']    - the word is simply gone
subword_tokenize('runner')     # ['run', 'ner'] - related to 'running'

Choosing character-level tokenization because it 'has no vocabulary problem'. It does not, but it multiplies sequence length by roughly four, and attention cost grows with the square of sequence length — so the same context window costs about sixteen times more compute.

nn.Embedding as a lookup table

What it is

nn.Embedding(V, d) holds a (V, d) weight matrix and, given integer indices, returns the corresponding rows. It is mathematically identical to one-hot encoding each index into a length-V vector and multiplying by the same matrix — the one-hot vector selects exactly one row. Computationally the two are not remotely the same: the matrix multiply materialises a (batch, seq, V) tensor and does batch × seq × V × d multiply-accumulates, almost all of them against zeros, while the lookup does a gather and touches only the rows it needs.

Why it matters

This is a real distinction worth stating, not pedantry. With V = 50,000 and d = 768, a batch of 8 sequences of 1024 tokens one-hot encoded is 400 million floats — around 1.6 GB — to compute something a gather does with 8192 row copies. The gradient side matters too: backward through the gather writes into only the rows that appeared in the batch, which is why an embedding table trains sparsely and why a token that never occurs in your fine-tuning data keeps its pretrained vector untouched.

In the real world: Every product and user gets a vector, the basis of many recommendation systems.

Visual example

nn.Embedding(8, 4)

An embedding layer is a table with a row per token id. Indexing it is the entire forward pass.

Learned parameters

(V × d numbers, trained by backprop)

embed.weight - shape (8, 4)
dim 0dim 1dim 2dim 3
id 00.11-0.420.070.35
id 1-0.280.190.51-0.06
id 20.440.33-0.120.28
id 30.02-0.170.220.4
id 4-0.350.480.09-0.21

embed(torch.tensor([2]))

gathers row 2 - no multiplication at all

one_hot([2]) @ weight

same answer, via 8 × 4 multiplies against zeros

Two spellings, one result, wildly different cost

Cost at a realistic scale (V=50k, d=768, batch 8 × seq 1024)

lookup: 8192 row gathers, ~25 MB of output

one-hot: (8, 1024, 50000) float intermediate = 1.6 GB before the matmul

multiplies: 0 vs 3.1 × 10^11, of which 99.998% are × 0

Why this matters

Knowing they are the same function lets you reason about embeddings as a linear layer when that helps — tied input/output weights in language models rely on exactly this. Knowing they are not the same computation stops you implementing the expensive one.

Common misconception

Wrong idea: an embedding layer is a special kind of neural network layer that learns representations.

Correct idea: it is a lookup table of learned rows. There is no nonlinearity, no mixing between dimensions, and no computation beyond indexing. The representations are learned only because backprop writes into the rows that got used.

import torch
import torch.nn as nn
import torch.nn.functional as F

V, d = 8, 4
embed = nn.Embedding(V, d)
ids = torch.tensor([2, 5])

# The lookup: a gather of two rows.
via_lookup = embed(ids)

# The 'equivalent' one-hot matmul: allocates a (2, 8) dense tensor first.
one_hot = F.one_hot(ids, num_classes=V).float()
via_matmul = one_hot @ embed.weight

torch.allclose(via_lookup, via_matmul)   # True - same numbers
embed(ids).shape, one_hot.shape          # (2, 4) vs the (2, 8) we never needed

Writing your own one-hot layer because it feels more explicit, then hitting out-of-memory on a realistic vocabulary and blaming the model size. The embedding table is the same; the one-hot intermediate is what does not fit.

Embedding dimensionality

What it is

embedding_dim is how many numbers describe each token. It is a capacity decision: too few dimensions and distinct tokens are forced to share directions in a cramped space, losing distinctions the model needed; too many and you pay for parameters that carry little extra information. The embedding table alone is V × d parameters, which for a large vocabulary is often a substantial fraction of a small model's total size.

Why it matters

It is one of the few architecture hyperparameters whose cost you can compute exactly before training anything, which makes it a good place to be deliberate rather than superstitious. A 50,000-token vocabulary at d = 128 is 6.4M parameters; at d = 1024 it is 51M. In a 100M-parameter model that is the difference between the embeddings being a rounding error and being half the model. Returns also flatten: most tasks show a clear knee beyond which extra dimensions buy very little held-out improvement.

In the real world: Bigger vectors capture more but cost more to store and search across millions of products.

Visual example

Capacity vs cost

Held-out quality against embedding dimension: a clear knee, then a flat stretch you are still paying for.

validation qualityembedding params (normalised)
paying 4× for +4%02004006008001,0000.50.60.70.80.91embedding dimensionrelative held-out qualitythe kneemost of the quality

Why this matters

Dimension is usually inherited rather than chosen. Plotting the two curves on the same axes is what turns it back into a decision with a defensible answer.

Cost is linear in d; quality is concave in d. The knee is where those two curves stop agreeing.

import torch.nn as nn

V = 50_000
for d in (64, 128, 256, 512, 1024):
    table = nn.Embedding(V, d)
    params = sum(p.numel() for p in table.parameters())
    print(f'd={d:>5}  params={params:>12,}  fp32 MB={params * 4 / 1e6:7.1f}')

# d=   64  params=   3,200,000  fp32 MB=   12.8
# d= 1024  params=  51,200,000  fp32 MB=  204.8

Copying d = 768 from BERT into a model trained on a few thousand examples. The dimension was chosen for a model pretrained on billions of tokens; at small data scale it mostly gives the model more room to memorise.

Why similar tokens get similar vectors

What it is

Nothing in the architecture asks for this. It falls out of the objective: two tokens that appear in interchangeable contexts are asked to support the same predictions, so gradient descent pushes their rows toward whatever vector serves those predictions well. king and queen both frequently precede reigned and follow the, so both rows get nudged by similar gradients, and they converge. The similarity is a *consequence* of shared predictive role, not a property the model was told to encode.

Why it matters

It explains both the power and the limits. The power: an embedding table trained on enough text transfers useful structure to any downstream task, because the geometry already encodes distributional relationships nobody labelled. The limit: proximity means 'used in similar contexts', which is not the same as 'means a similar thing'. Antonyms are famously close — hot and cold occupy nearly identical syntactic slots — so an embedding distance is evidence about usage, never a semantic guarantee.

In the real world: Why a search for “sneakers” also finds “running shoes”: the basis of semantic search.

Visual example

Learned geometry

Toy embeddings projected to two dimensions. Nobody labelled the clusters — the objective produced them.

royaltyfruitverbs of motion
-1-0.500.51-1-0.500.51projected dimension 1projected dimension 2king, queen, prince, throneapple, pear, banana, plumwalk, ran, sprint

Why this matters

Seeing the clusters makes the mechanism concrete: the rows moved because the same predictions needed them, which is why embeddings transfer to tasks nobody trained them on.

Common misconception

Wrong idea: embeddings are trained to place similar words together.

Correct idea: they are trained to predict text. Clustering is a side effect of tokens that are predictively interchangeable receiving interchangeable gradients.

Distance here reflects shared context, so hot/cold and rise/fall would also land close together.

import torch
import torch.nn.functional as F

# Trained embeddings, projected to 2-D for inspection.
words = ['king', 'queen', 'prince', 'apple', 'pear', 'banana']
vecs = torch.tensor([
    [0.82, 0.71], [0.78, 0.64], [0.86, 0.58],
    [-0.62, 0.44], [-0.55, 0.52], [-0.68, 0.38],
])

sim = F.cosine_similarity(vecs[0:1], vecs, dim=1)
dict(zip(words, sim.round(decimals=2).tolist()))
# {'king': 1.0, 'queen': 1.0, 'prince': 0.99, 'apple': -0.21, ...}

Treating cosine similarity in embedding space as a meaning measure and shipping it as a synonym finder. Antonyms and co-hyponyms score extremely high because they share contexts; increase and decrease will be near neighbours in almost any distributionally trained space.

The vocabulary-size versus sequence-length tradeoff

What it is

A tokenizer's granularity trades two costs against each other. A large vocabulary keeps more words whole, so the same document becomes fewer, longer tokens — shorter sequences, cheaper attention, but a bigger embedding and output matrix. A small vocabulary splits more aggressively: cheaper tables, longer sequences, and a quadratically more expensive attention step. The product that matters is not either number alone but total compute for the text you actually process.

Why it matters

The asymmetry decides it. Embedding and output-projection cost grows *linearly* with vocabulary size; attention cost grows *quadratically* with sequence length. So for long-context models the pressure runs toward larger vocabularies, because paying linearly to buy a shorter sequence is a good trade. It also has a practical consequence people meet without understanding: the same text costs a different number of tokens on two models, and non-English text often costs two to three times more on a tokenizer fitted mostly to English.

In the real world: Affects how many tokens a document becomes, and so how much each API call costs.

# The same paragraph under two vocabulary budgets.
chars = 4800

for vocab, chars_per_token in [(8_000, 3.1), (32_000, 4.0), (128_000, 4.9)]:
    seq = round(chars / chars_per_token)
    attention_cost = seq ** 2                 # quadratic in sequence length
    table_cost = vocab * 768                  # linear in vocabulary size
    print(f'V={vocab:>7,}  seq={seq:>5}  attn={attention_cost:>10,}  table={table_cost:>12,}')

# V=  8,000  seq= 1548  attn= 2,396,304  table=   6,144,000
# V=128,000  seq=  980  attn=   960,400  table=  98,304,000

Comparing two models' context windows as if the tokens were the same unit. An 8k window on a coarse tokenizer can hold more text than a 12k window on a fine one, and your non-English users will hit the limit far sooner than your benchmark suggested.

Tokenizer mismatch

What it is

Token IDs are meaningful only relative to the tokenizer that produced them. Feed a model IDs from a different tokenizer and nothing errors: the indices are in range, the embedding lookup succeeds, the forward pass runs, and a fluent-looking but content-free output comes out. Every ID has silently been reinterpreted as a different token, so the model is reading a shuffled cipher of your text.

Why it matters

It is the single most damaging silent failure at this boundary, because every loud safety net is absent. There is no shape error, no dtype error, no NaN, no exception — just quietly wrong predictions. It happens in real systems whenever a tokenizer and a checkpoint are versioned separately: someone upgrades the model but the serving image caches the old vocabulary file, or a training pipeline builds its own vocabulary while inference uses the pretrained one. The fix is structural, not vigilant: ship the tokenizer *inside* the model artifact and assert the vocabulary hash at load time.

In the real world: Using the wrong tokenizer with a model gives garbage answers in production.

Visual example

Silent failure

Nothing raises. The IDs are valid, the shapes are right, and the meaning is gone.

encoded by tokenizer A (the model's own)
idtoken
1045i
2293love
2204good
4157coffee
same IDs, wrong vocabulary
decoded by tokenizer B (the one in the serving image)
idtoken
1045##ology
2293brick
2204##ising
4157Tuesday

Why this matters

Because there is no exception anywhere in this path, the only defence is an assertion you add deliberately. This is why tokenizer and weights belong in one versioned artifact.

Common misconception

Wrong idea: a tokenizer mismatch would obviously blow up or produce gibberish output.

Correct idea: the output is usually fluent. The model still generates plausible text — just text responding to a prompt it was never given.

The model happily embeds all four rows and produces confident logits about a sentence nobody wrote.

import hashlib
import json

def vocab_fingerprint(vocab: dict) -> str:
    payload = json.dumps(vocab, sort_keys=True).encode()
    return hashlib.sha256(payload).hexdigest()[:12]

# Saved beside the weights at training time.
checkpoint = {'state_dict': ..., 'vocab_hash': 'a1b2c3d4e5f6', 'vocab_size': 32_000}

def load_for_serving(checkpoint, tokenizer):
    actual = vocab_fingerprint(tokenizer.vocab)
    if actual != checkpoint['vocab_hash']:
        raise RuntimeError(
            f"tokenizer mismatch: model expects {checkpoint['vocab_hash']}, got {actual}"
        )
    return build_model(checkpoint)

Validating only that vocab_size matches. Two tokenizers of identical size and completely different ID assignments pass that check, which is exactly the case where the failure is invisible.