Embedding layers
Learned lookup tables that turn tokens into vectors.
Create a free account Try part of it here
Why it matters
Neural networks work with numbers. But much of the world arrives as IDs: words, tokens, products, customers. An embedding layer gives every ID its own short list of numbers, called a vector. Training then nudges those vectors around. Things that get used in similar ways end up close together. It's the first layer of every language model. The same idea powers "customers also bought" lists and lookalike audiences.
What you'll be able to do
- Explain an embedding layer as a learned lookup table, equivalent to one-hot × matrix
- Use nn.Embedding and see which rows receive gradients
- Learn product embeddings from co-purchases and find similar items
- Relate embedding tables to LLM tokens and to the embedding APIs from Phase 1
What it covers
- A locker for every ID
- A lookup is a multiplication in disguise
- Only the rows you use get updated
- Learning what "similar" means
- Worked example: a tiny product table
- Training on shopping baskets
- Embeddings inside language models
- How this relates to embedding APIs
A locker for every ID
Picture a gym with numbered lockers. Your member number tells you which locker is yours. You open it and take out what's inside. You don't search the whole room.
An embedding layer works the same way. A lot of data reaches AI as IDs: whole numbers that name a thing. Word 812. Product 3. Customer 40,117. An ID is a name, not an amount. Product 4 isn't "twice" product 2. So a network can't use the raw number in a sum.
Instead, the layer keeps a table with one row per ID. Each row is a vector, a short list of numbers. The vector for an ID is called its embedding. Give the layer ID 3, and it hands back row 3. That's all the layer does on the way forward.
Here's the twist. The numbers in the table are weights. Training adjusts them like any other weights in a network. The table starts out random. Over time it fills with useful vectors.
Looking up rows
The code below makes a table for 5 IDs, with 3 numbers per ID. Then it looks up IDs 3, 0 and 3.
import torch import torch.nn as nn torch.manual_seed(0) emb = nn.Embedding(num_embeddings=5, embedding_dim=3) # 5 IDs, a 3-number vector each ids = torch.tensor([3, 0, 3]) print(emb(ids)) # rows 3, 0, 3 of the table one_hot = nn.functional.one_hot(ids, 5).float() # (3, 5) print(torch.allclose(one_hot @ emb.weight, emb(ids))) # identical to one-hot @ table emb(ids).sum().backward() print("rows with gradient:", [i for i in range(5) if emb.weight.grad[i].abs().sum() > 0])
The code does three things.
- It prints rows 3, 0 and 3 of the table. Row 3 shows up twice, because you asked for ID 3 twice.
- It does the same job a second, slower way, using one-hot vectors (explained next). It prints
True, because both ways give the same numbers. - It runs backpropagation and asks which rows got a gradient. It prints rows 0 and 3.
(As in the rest of this phase, torch is the course's in-browser PyTorch subset.)
No calculation, just a lookup. What makes it useful is that training learns the table's contents.
A lookup is a multiplication in disguise
A one-hot vector is a list of zeros with a single 1 in it. The 1 sits at the ID's position. For ID 3 out of 5 IDs, it's [0, 0, 0, 1, 0]. Counting starts at 0, as in Python.
Now multiply a one-hot vector by the table. Each row gets multiplied by one number from the one-hot vector, and the results are added up. Every row is multiplied by 0 except one. So only the row for that ID survives.
Take a table with three rows: [1, 2], [3, 4] and [5, 6]. The one-hot vector for ID 2 is [0, 0, 1]. The sum is 0 × [1, 2] + 0 × [3, 4] + 1 × [5, 6]. That's [5, 6], which is row 2.
In plain words: "one-hot times table" and "fetch the row" give the same answer. The layer fetches the row because it's far cheaper.
Why cheaper? A language model might know 128,000 tokens. Each one-hot vector would hold 128,000 numbers, all zeros but one. Multiplying by all those zeros is wasted work. Fetching a row skips it.
table = [[2, 0], [1, 1], [0, 3], [4, 4]] one_hot = [0, 1, 0, 0] print([sum(o * row[j] for o, row in zip(one_hot, table)) for j in range(2)])
Show the answer
[1, 1]
The 1 sits at position 1, so every row except row 1 is multiplied by 0. What's left is row 1, [1, 1].
Only the rows you use get updated
A gradient is the signal from backpropagation that says how to change a weight. In the code above, only rows 0 and 3 got one. That has two effects.
- Only looked-up rows get gradients. The other rows took no part in the calculation, so there's nothing to change. Each training step updates a handful of rows. The table itself may be huge.
- An ID used twice collects gradient from both uses. Row 3 was looked up twice, so it got two helpings of gradient, added together.
Show the answer
At most 300
Only the rows that were looked up take part in the calculation, so only they get gradients. It's "at most" because a gradient can happen to come out as zero.
Learning what "similar" means
Think about a supermarket checkout. Nobody tags a tent and a sleeping bag as "camping gear." Yet the same shoppers often buy them on the same trip. Watch enough baskets and the pattern shows up by itself.
An embedding table can learn that pattern. The vectors start random and mean nothing. They gain meaning from the training task. Here's a classic recipe, used by word2vec and many recommenders:
- Positive pairs. Take two products from the same basket. The right answer is "bought together."
- Negative pairs. Take two products at random. The right answer is "not bought together." A random pair is usually a good guess for that.
- Score each pair. Multiply the two vectors number by number, and add up the results. This is the dot product, from Phase 2.
- Adjust. Training pushes the score up for positive pairs and down for negative pairs.
Here's the score as a formula. Product A's vector holds the numbers . Product B's vector holds . The letter is how many numbers each vector has.
In plain words: pair up the two vectors' numbers, multiply each pair, and add. Vectors that point the same way get a big score. Vectors that point in different directions get a small one. Longer vectors push the score further, too.
print(1 * 3 + 2 * 4)
Show the answer
11
Multiply each pair and add: 3 + 8 = 11.
A quick example with 2 numbers per vector. Say the tent is [0.9, 0.1] and the sleeping bag is [0.8, 0.3]. The score is 0.9 × 0.8 + 0.1 × 0.3 = 0.72 + 0.03 = 0.75. If the kettle is [0.1, 0.9], the tent and kettle score 0.09 + 0.09 = 0.18. The worked example below does the same sums in Python.
Go deeper From a score to a yes-or-no answer
A score can be any number, but the target is yes (1) or no (0). Training squeezes the score into a probability between 0 and 1 with the sigmoid, which you met in logistic regression. A big score becomes close to 1. A very negative score becomes close to 0.
The loss is binary cross-entropy. It's small when the probability is near the right answer and large when it's far off. In the training code below, binary_cross_entropy_with_logits does both steps at once. "Logits" is the name for raw scores before the sigmoid.
Worked example: a tiny product table
A shop has four products, numbered 0 to 3: tent, sleeping bag, kettle and mug. Each one has a vector of 2 numbers. Look up the tent (ID 0) in two ways. Then score the tent against each of the other products.
names = ["tent", "sleeping bag", "kettle", "mug"] table = [[0.9, 0.1], [0.8, 0.3], [0.1, 0.9], [0.2, 0.7]] one_hot = [1, 0, 0, 0] # ID 0, the tent by_multiplying = [sum(o * row[j] for o, row in zip(one_hot, table)) for j in range(2)] print("lookup:", table[0]) print("one-hot times table:", by_multiplying) def score(a, b): return round(sum(x * y for x, y in zip(a, b)), 2) for i in [1, 2, 3]: print("tent and", names[i], "->", score(table[0], table[i]))
Both lookups give [0.9, 0.1]. The one-hot vector [1, 0, 0, 0] keeps row 0 and multiplies every other row by 0.
The scores come from multiplying number by number and adding:
- tent and sleeping bag: 0.9 × 0.8 + 0.1 × 0.3 = 0.72 + 0.03 = 0.75
- tent and kettle: 0.9 × 0.1 + 0.1 × 0.9 = 0.09 + 0.09 = 0.18
- tent and mug: 0.9 × 0.2 + 0.1 × 0.7 = 0.18 + 0.07 = 0.25
The sleeping bag scores highest, so this table "thinks" it goes with the tent. In real training, nobody types these numbers in. The model finds them from the baskets.
Training on shopping baskets
The next example runs the whole recipe. There are fifteen products in three departments: outdoor, kitchen and office. There are 400 made-up shopping baskets. Here's what the code does.
- Builds baskets. Each basket picks one department and three products from it. About one basket in five also gets a random extra item, as real shoppers do.
- Lists positive pairs. Every pair of products that share a basket.
- Trains for 300 steps. Each step scores 128 positive pairs and 128 random pairs, then updates the table. Each product gets 8 numbers.
Adamis an optimizer: the rule that updates the weights. - Finds neighbors. It compares every product with every other using cosine similarity, from Phase 1. It prints the three closest products to the tent, the kettle and the planner.
- Draws a picture. Each vector has 8 numbers, too many to plot. PCA, from Phase 2, squashes them down to 2. Each dot's color shows its department.
import numpy as np import torch import torch.nn as nn import matplotlib.pyplot as plt products = ["tent", "sleeping bag", "headlamp", "trail shoes", "rain jacket", "coffee mug", "kettle", "tea towels", "cutting board", "candle", "notebook", "desk lamp", "pen set", "planner", "laptop sleeve"] department = [0] * 5 + [1] * 5 + [2] * 5 # outdoor, kitchen, office (the model never sees this) rng = np.random.default_rng(0) baskets = [] for _ in range(400): d = rng.integers(3) items = set(rng.choice([i for i in range(15) if department[i] == d], size=3, replace=False).tolist()) if rng.random() < 0.2: items.add(int(rng.integers(15))) # sometimes a random extra item baskets.append(sorted(items)) pairs = torch.tensor([(a, b) for bk in baskets for a in bk for b in bk if a != b]) torch.manual_seed(0) emb = nn.Embedding(15, 8) opt = torch.optim.Adam(emb.parameters(), lr=0.05) for step in range(300): pos = pairs[torch.randint(0, len(pairs), (128,))] # bought together neg = torch.randint(0, 15, (128, 2)) # random pairs a, b = torch.cat([pos[:, 0], neg[:, 0]]), torch.cat([pos[:, 1], neg[:, 1]]) target = torch.cat([torch.ones(128), torch.zeros(128)]) score = (emb(a) * emb(b)).sum(dim=1) # dot product of the two vectors loss = nn.functional.binary_cross_entropy_with_logits(score, target) opt.zero_grad() loss.backward() opt.step() E = emb.weight.detach().numpy() En = E / np.linalg.norm(E, axis=1, keepdims=True) sim = En @ En.T # cosine similarity between all products for i in [0, 6, 13]: nearest = np.argsort(-sim[i])[1:4] print(f"{products[i]:<10} → {[products[j] for j in nearest]}") xy = (E - E.mean(0)) @ np.linalg.svd(E - E.mean(0))[2][:2].T # PCA to 2-D for a picture plt.scatter(xy[:, 0], xy[:, 1], c=department, cmap="viridis", s=60) for (x, y), name in zip(xy, products): plt.annotate(name, (x, y), fontsize=8)
The department labels are only used to build the baskets and color the plot. The model never sees them. Yet its vectors sort themselves into three groups, purely from what's bought together.
That's the same thing that makes "king" and "queen" neighbors in word embeddings. Items used in similar contexts get similar vectors.
Note: the neighbors use cosine similarity, not the raw dot product. Cosine compares only the direction of two vectors, so a vector's length can't sway the ranking.
Show the answer
Nothing yet: they're random numbers
The table starts random. Meaning only appears as training pushes co-purchased products toward each other.
Embeddings inside language models
A language model reads tokens: words or pieces of words, as you saw in Phase 1. Each token has an ID. So the model's very first layer is an embedding table.
- Token embeddings. The first layer is an
nn.Embedding(vocab_size, d_model). Thevocab_sizeis how many different tokens the model knows. Thed_modelis how many numbers each token gets, called the model width. Llama 3 8B knows about 128,000 tokens, with 4,096 numbers each. Rows times columns gives about 525 million weights, in this one table alone. - Position embeddings. A second table (or a formula) adds where each token sits in the text. You'll meet these in Phase 5.
- Tied weights. The model's last layer gives a score to every token in the vocabulary, to pick the next one. Many models reuse the token embedding table, turned on its side, as that last layer. GPT-2 does this. It saves weights, and it links "reading" a token to "writing" it.
print(50_000 * 768)
Show the answer
38400000
One row per token and one column per number: 50,000 × 768 = 38,400,000 weights. Python prints it with no commas: 38400000.
How this relates to embedding APIs
In Phase 1 you used embedding APIs. They return one vector for a whole text. That vector comes from a model's later layers, after the model has read the whole text. Often the model was trained specifically so that similar texts get similar vectors.
An nn.Embedding table is simpler. It holds one vector per ID. It learns only from the task you train it on. The geometry is the same: close vectors mean similar things. The scope is different.
For marketers: product, customer and campaign embeddings power "customers who bought this also bought", lookalike audiences and content recommendations. Train vectors on behavior (co-purchases, co-views, sessions), and similarity search over them does the rest.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why use nn.Embedding instead of multiplying one-hot vectors by a weight matrix?