Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
The previous lesson turned text into token IDs. Those IDs are addresses, not meanings. The same ID can appear in a billing dispute and in a device instruction:
1"dispute the charge" -> [91, 12, 407]
2"charge the scanner" -> [407, 12, 982]Both lines contain ID 407, yet invoice and scanner point to different uses. If both lookups start from one row, where can that difference enter? An embedding stores a vector of numbers for each ID. A contextual representation can then rewrite that vector using words around this particular occurrence.
First inspect the static table. Then watch the same token leave with two different states.
![Diagram showing Sentence, Token IDs, Static row E[id], and Mix neighbors.](/cdn/content-image/fundamentals/word-embeddings-contextual-representations/diagrams/_generated/content_diagram_0_dark.png?v=ddfb5db1a51b)
The first stop is the lookup. Its limitation gives context mixing a job.
A token ID selects one stored row
Let vocabulary size be V and embedding dimension be d. The model stores an embedding matrix E with shape V x d.
The table answers one narrow question: given an ID, which vector enters the model?
- Row
E[token_id]is the starting vector for that token. - Training moves rows so tokens used in similar situations become useful for the prediction task.
- Before any context mixing, every occurrence of the same ID receives that same row.
A one-hot row vector of length V with a 1 at token_id selects the same row. Multiplying it by E writes the lookup as matrix algebra.
If charge is ID 0 in a tiny table, both billing and device sentences start at E[0]. A production tokenizer might use 407 instead. The rule doesn't change: the integer is an index, not a sense label.
The lab uses a three-row table so every index is visible. Before running it, predict whether the two printed rows can differ.
1vocab = {"charge": 0, "invoice": 1, "scanner": 2}
2embedding_table = [
3 [0.80, 0.20], # charge
4 [0.20, 1.00], # invoice
5 [1.00, 0.10], # scanner
6]
7
8billing_charge = embedding_table[vocab["charge"]]
9device_charge = embedding_table[vocab["charge"]]
10
11print("billing start:", billing_charge)
12print("device start:", device_charge)
13print("same starting row:", billing_charge == device_charge)1billing start: [0.8, 0.2]
2device start: [0.8, 0.2]
3same starting row: TrueThe rows are equal, so no lookup-only operation can resolve the two senses. Training can make one row useful across its uses, but only a context-dependent computation can specialize an occurrence.
Nearby words leave a fingerprint
If encoder and decoder keep seeing layer and mask, their neighborhood fingerprints start to resemble each other. The same logic puts cache near prefix when both occur with reuse language. This is the distributional hunch: usage supplies a weak signal about role.
The simplest measurement is a co-occurrence count. For each token, count words inside a fixed context window.
1from collections import Counter, defaultdict
2
3messages = [
4 "encoder layer uses attention mask".split(),
5 "decoder layer uses attention mask".split(),
6 "encoder block mixes token context".split(),
7 "cache hit reuses prompt prefix".split(),
8 "prefix cache skips prompt compute".split(),
9]
10
11window = 2
12neighbors = defaultdict(Counter)
13
14for message in messages:
15 for center_index, center in enumerate(message):
16 left = max(0, center_index - window)
17 right = min(len(message), center_index + window + 1)
18 for context_index in range(left, right):
19 if context_index != center_index:
20 neighbors[center][message[context_index]] += 1
21
22print("encoder context:", neighbors["encoder"].most_common(4))
23print("decoder context:", neighbors["decoder"].most_common(4))
24print("cache context:", neighbors["cache"].most_common(4))1encoder context: [('layer', 1), ('uses', 1), ('block', 1), ('mixes', 1)]
2decoder context: [('layer', 1), ('uses', 1)]
3cache context: [('hit', 1), ('reuses', 1), ('prefix', 1), ('skips', 1)]The sample exposes the fingerprints: encoder and decoder share layer and uses, while cache sees prefix and reuse language. The rows are still counts, not compact coordinates.
A count row is high-dimensional and noisy. Latent semantic analysis compressed a term-document matrix with singular value decomposition (SVD).[1]
The same move works on word-context counts: keep directions that explain the main neighborhoods, drop the rest.
Raw counts can mistake a common word for an informative neighbor. Positive pointwise mutual information (PPMI) keeps pairs that occur more often than chance and floors negative values at zero.
If #(w, c) is the count for word w with context c,
where N is the total of all counts, #(w) is the row sum, and #(c) is the column sum.
After compression, cosine similarity asks whether two vectors point the same way, ignoring length:
Parallel vectors score 1. Orthogonal vectors score 0. Opposite vectors score -1.
The next lab makes the geometry visible. It starts with a 4×4 fixture: architecture words (encoder, decoder) share layer and mask; serving words (cache, prefix) share hit and reuse. It converts counts to PPMI, then keeps two SVD components. The power iteration is a small stand-in for a full SVD: find the strongest direction, subtract it, then find the next one.
Predict the result before running it: which pair should have cosine 1, and which pair should be separated?
1import math
2
3words = ["encoder", "decoder", "cache", "prefix"]
4counts = [
5 [8, 6, 0, 0], # encoder
6 [7, 6, 0, 0], # decoder
7 [0, 0, 8, 5], # cache
8 [0, 0, 7, 6], # prefix
9]
10
11def dot(a, b):
12 return sum(x * y for x, y in zip(a, b))
13
14def cosine(a, b):
15 return dot(a, b) / math.sqrt(dot(a, a) * dot(b, b))
16
17def ppmi_matrix(table):
18 row_sums = [sum(row) for row in table]
19 col_sums = [sum(table[i][j] for i in range(len(table))) for j in range(len(table[0]))]
20 total = sum(row_sums)
21 result = []
22 for i, row in enumerate(table):
23 out = []
24 for j, count in enumerate(row):
25 expected = row_sums[i] * col_sums[j] / total
26 pmi = math.log((count + 1e-9) / (expected + 1e-9))
27 out.append(max(pmi, 0.0))
28 result.append(out)
29 return result
30
31def matvec(matrix, vec):
32 return [sum(row[j] * vec[j] for j in range(len(vec))) for row in matrix]
33
34def transpose(matrix):
35 return [list(col) for col in zip(*matrix)]
36
37def normalize(vec):
38 length = math.sqrt(sum(x * x for x in vec))
39 return [x / length for x in vec]
40
41def leading_component(matrix, steps=80):
42 left = [1.0] * len(matrix)
43 right_space = transpose(matrix)
44 for _ in range(steps):
45 right = normalize(matvec(right_space, left))
46 left = normalize(matvec(matrix, right))
47 scaled_right = matvec(right_space, left)
48 singular = math.sqrt(sum(x * x for x in scaled_right))
49 return left, singular
50
51def minus_outer(matrix, left, singular, right):
52 return [
53 [matrix[i][j] - singular * left[i] * right[j] for j in range(len(right))]
54 for i in range(len(left))
55 ]
56
57ppmi = ppmi_matrix(counts)
58left1, s1 = leading_component(ppmi)
59right1 = normalize(matvec(transpose(ppmi), left1))
60left2, s2 = leading_component(minus_outer(ppmi, left1, s1, right1))
61vectors = [
62 [left1[i] * math.sqrt(s1), left2[i] * math.sqrt(s2)]
63 for i in range(len(words))
64]
65
66print("singular values:", round(s1, 3), round(s2, 3))
67print("encoder vs decoder:", round(cosine(vectors[0], vectors[1]), 3))
68print("encoder vs cache:", round(cosine(vectors[0], vectors[2]), 3))1singular values: 1.418 1.348
2encoder vs decoder: 1.0
3encoder vs cache: 0.0The architecture rows become parallel. The serving rows land on a second axis at right angles to the first. Similar neighborhoods became similar directions, which is the distributional hunch made geometric.

encoder sits with decoder and away from cache.The toy blocks make the split obvious. Real corpora add messy wording, rare contexts, and words that play several roles. Counting is one route to this geometry, not the only route.
Predict the neighbors instead
Counting is explicit. Word2Vec learns the same kind of static table by trying to predict neighbors:
- CBOW (continuous bag-of-words) predicts a center token from its surrounding tokens.
- Skip-gram predicts surrounding tokens from a center token.
There is no label saying “similar.” Each correct prediction nudges vectors so tokens that support similar predictions can share a neighborhood. Mikolov and colleagues introduced the two architectures in 2013.[2]

hit three visible neighbors. CBOW folds them into one center prediction. Skip-gram reverses the arrows and creates three positive pairs.Skip-gram turns each window into training pairs. With window size 2, center hit produces one positive pair for each visible neighbor. The center stays first in each pair.
Count those pairs before looking at the output: the window reaches request, cache, and layer, but not today.
1tokens = "request hit cache layer today".split()
2window = 2
3pairs = []
4
5for center_index, center in enumerate(tokens):
6 left = max(0, center_index - window)
7 right = min(len(tokens), center_index + window + 1)
8 for context_index in range(left, right):
9 if context_index != center_index:
10 pairs.append((center, tokens[context_index]))
11
12hit_pairs = [pair for pair in pairs if pair[0] == "hit"]
13print(hit_pairs)1[('hit', 'request'), ('hit', 'cache'), ('hit', 'layer')]The output confirms the boundary: today is three positions away, so it never becomes a positive pair for hit. Each listed pair supplies one local prediction target.
Scoring every vocabulary item for every pair is expensive. A Skip-gram trainer can instead treat the observed neighbor as a positive example and draw a few unobserved tokens as negative samples. Levy and Goldberg showed that Skip-gram with negative sampling is closely related to factorizing a shifted PMI matrix. That link explains why prediction geometry often agrees with the count-based SVD geometry above.[3]
The loss below is a small, inspectable version of that update. It pushes the center toward the true neighbor and away from sampled ones. σ is the logistic sigmoid.
A high positive dot product lowers the first term. A negative dot product lowers each negative-sample term.
1import math
2
3center = [1.0, 0.0]
4observed_neighbor = [1.2, 0.1]
5wrong_neighbor = [-1.0, 0.1]
6negative_samples = [[-0.9, -0.2], [-1.1, 0.0]]
7
8def dot(a, b):
9 return sum(x * y for x, y in zip(a, b))
10
11def sigmoid(x):
12 return 1.0 / (1.0 + math.exp(-x))
13
14def negative_sampling_loss(center_vector, positive_vector, negatives):
15 positive_loss = -math.log(sigmoid(dot(center_vector, positive_vector)))
16 negative_loss = sum(
17 -math.log(sigmoid(-dot(center_vector, sample)))
18 for sample in negatives
19 )
20 return positive_loss + negative_loss
21
22observed_loss = negative_sampling_loss(center, observed_neighbor, negative_samples)
23wrong_loss = negative_sampling_loss(center, wrong_neighbor, negative_samples)
24
25print("observed pair loss:", round(observed_loss, 3))
26print("wrong pair loss:", round(wrong_loss, 3))
27print("training prefers observed pair:", observed_loss < wrong_loss)1observed pair loss: 0.892
2wrong pair loss: 1.942
3training prefers observed pair: TrueThe observed pair has lower loss, so training prefers it. Repeating that update puts tokens with similar neighbor predictions near one another. No analogy puzzle is required to see the signal.
Local windows are one view. GloVe starts with the global table of how often each pair occurred anywhere in the corpus.
Fit the global count table
GloVe starts from those global co-occurrence counts. It fits a weighted least-squares objective: a word vector and a context vector, plus two biases, should reconstruct the logarithm of each observed count.[4]
The dot product is the model's compatibility score for that word-context pair.
The logarithm compresses counts that span many orders of magnitude. A weighting function then limits how much very common pairs dominate:
The paper's default is and . Rare pairs get small weights. Very frequent pairs stop gaining extra weight past the cutoff.
The lab fixes the model score at log(30). Predict which count has zero residual, then see how weighting changes with frequency.
1import math
2
3def squared_glove_residual(observed_count, model_score):
4 target = math.log(observed_count)
5 return (model_score - target) ** 2
6
7def glove_weight(count, xmax=100, alpha=0.75):
8 if count < xmax:
9 return (count / xmax) ** alpha
10 return 1.0
11
12model_score = math.log(30)
13matching_error = squared_glove_residual(30, model_score)
14different_error = squared_glove_residual(3, model_score)
15
16print("log targets:", [round(math.log(count), 3) for count in (3, 30, 300)])
17print("matching count error:", round(matching_error, 3))
18print("different count error:", round(different_error, 3))
19print("weights for 3, 30, 100:", [round(glove_weight(count), 3) for count in (3, 30, 100)])1log targets: [1.099, 3.401, 5.704]
2matching count error: 0.0
3different count error: 5.302
4weights for 3, 30, 100: [0.072, 0.405, 1.0]The score matches log(30), so count 30 has zero residual. Count 3 misses that target and pays a residual of 5.302; the weighting function also gives rarer pairs less influence.
Word2Vec and GloVe still give each known spelling one static row. That makes lookup cheap, but leaves two gaps: a new spelling has no row, and an ambiguous word has only one.
Build unknown spellings from pieces
Technical text constantly produces variants: tokenize, tokenized, tokenizer, plus typos mixed into logs. FastText represents a known word with character n-grams as well as its whole-word identity, so related spellings share parameters.[5]
The paper uses n-gram lengths 3 through 6 with boundary markers. The lab uses length 3 so every window is visible.
For an out-of-vocabulary (OOV) spelling, there's no learned whole-word row. FastText can still sum n-gram vectors it has already learned.

n=3 windows share seven bucket rows. The unseen spelling omits a whole-word row but can still sum the n-gram vectors it has in common with tokenize.1def character_ngrams(word, n=3):
2 marked = f"<{word}>"
3 return {marked[i:i + n] for i in range(len(marked) - n + 1)}
4
5known = character_ngrams("tokenize")
6new_form = character_ngrams("tokenized")
7shared = sorted(known & new_form)
8
9print("shared pieces:", shared)
10print("new spelling can reuse pieces:", len(shared) > 0)
11print("tokenize-only pieces:", sorted(known - new_form))
12print("tokenized-only pieces:", sorted(new_form - known))1shared pieces: ['<to', 'eni', 'ize', 'ken', 'niz', 'oke', 'tok']
2new spelling can reuse pieces: True
3tokenize-only pieces: ['ze>']
4tokenized-only pieces: ['ed>', 'zed']The output isolates the reusable pieces. A trained FastText model would sum learned vectors for them. For tokenized, it would skip a whole-word row that was never stored.
Subword composition helps with unfamiliar surface forms. It still can't decide which meaning an already-known ambiguous token carries.
One static row can't choose a sense
Read these two messages:
1"Dispute the charge on invoice 8142."
2"Charge the scanner before the lab demo."The spelling charge is the same. A static lookup returns the same vector, even though the first case belongs near billing language and the second near device language.
Before running the lab, predict whether either similarity score can notice that difference.
1import math
2
3static = {
4 "charge": [0.8, 0.2],
5 "invoice": [0.2, 1.0],
6 "scanner": [1.0, 0.1],
7}
8
9def dot(a, b):
10 return sum(x * y for x, y in zip(a, b))
11
12def cosine(a, b):
13 return dot(a, b) / math.sqrt(dot(a, a) * dot(b, b))
14
15billing_charge = static["charge"]
16device_charge = static["charge"]
17
18print("same charge vector:", billing_charge == device_charge)
19print("billing similarity to invoice:", round(cosine(billing_charge, static["invoice"]), 3))
20print("device similarity to invoice:", round(cosine(device_charge, static["invoice"]), 3))1same charge vector: True
2billing similarity to invoice: 0.428
3device similarity to invoice: 0.428Both comparisons match because the two charge vectors are identical. Every downstream decision therefore starts from the same compromise. Sentence evidence has to enter after the lookup.
Mix in a neighbor from this sentence
The lookup stays fixed, so context enters in layers above it. A Transformer can learn a weighted mix of neighboring states.
To isolate that move, take the shared charge row and average it with one clue from its sentence: billing with invoice, device with scanner.
The two starts are identical. Predict where the final states should move before running the lab.
1import math
2
3table = {
4 "charge": [0.8, 0.2],
5 "invoice": [0.2, 1.0],
6 "scanner": [1.0, 0.1],
7}
8
9def mix(token_vector, clue_vector):
10 return [0.5 * a + 0.5 * b for a, b in zip(token_vector, clue_vector)]
11
12def dot(a, b):
13 return sum(x * y for x, y in zip(a, b))
14
15def cosine(a, b):
16 return dot(a, b) / math.sqrt(dot(a, a) * dot(b, b))
17
18billing_state = mix(table["charge"], table["invoice"])
19device_state = mix(table["charge"], table["scanner"])
20
21print("billing charge state:", [round(x, 2) for x in billing_state])
22print("device charge state:", [round(x, 2) for x in device_state])
23print("same final state:", billing_state == device_state)
24print("billing vs invoice:", round(cosine(billing_state, table["invoice"]), 3))
25print("device vs invoice:", round(cosine(device_state, table["invoice"]), 3))1billing charge state: [0.5, 0.6]
2device charge state: [0.9, 0.15]
3same final state: False
4billing vs invoice: 0.879
5device vs invoice: 0.355The equal weights are illustrative, not learned parameters. Billing charge moves toward invoice (cosine 0.879), while device charge stays farther away (0.355). A trained attention layer learns which neighbors should contribute, and later layers can refine the state again.
![Vector plot of the toy contextualizer. Both charge uses start at E[0] equals 0.8, 0.2. Averaging with invoice at 0.2, 1.0 moves billing charge to 0.5, 0.6. Averaging with scanner at 1.0, 0.1 moves device charge to 0.9, 0.15.](/cdn/content-image/fundamentals/word-embeddings-contextual-representations/illustrations/_generated/context_window_dark.png?v=830f7d43f4c7)
E[0] = [0.8, 0.2]. Equal mixing with invoice or scanner sends the two occurrences to different final states.ELMo made this context-dependent representation explicit. A bidirectional language model produces token states, then a downstream task learns a weighted mix of its layers instead of using one context-free row.[6]
At position k, with layer states (character CNN) through (the top bidirectional LSTM),
The task-specific weights are normalized so they sum to 1. scales the whole mix. Lower and upper layers can therefore contribute different kinds of information.

charge, each recurrent layer concatenates forward and backward states. A downstream task learns weights over h0, h1, and h2, then scales their sum by gamma.BERT changed how deep bidirectional context is trained. Its masked language-model objective hides selected tokens and predicts them from both sides, so encoder self-attention can read evidence before and after a position.[7]
The binary visibility mask says who may attend. Learned attention weights say how much each allowed token contributes. Those are separate objects.
Not every model may look both ways
A BERT-style encoder token can use tokens on both sides during encoding. A GPT-style causal language model predicts the next token from the prefix, so its state at a position can't use later tokens while preserving that objective.
The original GPT work used a Transformer decoder for generative pretraining.[8]
At the first token in charge the scanner, the right-hand clue scanner is visible to an encoder and hidden from a causal decoder state at charge. Predict which list each mask should produce.
1tokens = ["charge", "the", "scanner"]
2encoder_visibility = [
3 [1, 1, 1],
4 [1, 1, 1],
5 [1, 1, 1],
6]
7causal_visibility = [
8 [1, 0, 0],
9 [1, 1, 0],
10 [1, 1, 1],
11]
12
13def visible_tokens(mask, position):
14 return [token for token, allowed in zip(tokens, mask[position]) if allowed]
15
16print("encoder state for charge sees:", visible_tokens(encoder_visibility, 0))
17print("causal state for charge sees:", visible_tokens(causal_visibility, 0))1encoder state for charge sees: ['charge', 'the', 'scanner']
2causal state for charge sees: ['charge']Both designs produce contextual states. Their visibility rules match different training objectives: one can use the whole input, while the other must preserve prefix-only prediction.

0, the encoder row is [1, 1, 1] and the causal row is [1, 0, 0]. Only the encoder can use the future clue scanner at that position.Even when two states are allowed to differ, a similarity score can still lie to you.
A shared direction can fake similarity
Embedding applications often use cosine similarity: a value near 1 means two vectors point in similar directions. That score can mislead when the space shares a large, uninformative direction.
Work on contextual representations found that token vectors can be anisotropic: many vectors share a dominant direction, so raw cosine stays high even for tokens that shouldn't be treated as alike.[9]
Two states can disagree in a task-relevant component while still pointing almost the same way overall.

[10, 0]. Before subtraction the two vectors differ by about 11.4°. Afterward their centered components point 180° apart.Predict what raw cosine will say before running the lab. The two states share a large horizontal component, so raw cosine treats them as almost identical. Subtracting their shared mean exposes vertical components that point in opposite directions.
1import math
2
3billing = [10.0, 1.0]
4device = [10.0, -1.0]
5
6def dot(a, b):
7 return sum(x * y for x, y in zip(a, b))
8
9def cosine(a, b):
10 return dot(a, b) / math.sqrt(dot(a, a) * dot(b, b))
11
12raw_similarity = cosine(billing, device)
13mean_direction = [(a + b) / 2 for a, b in zip(billing, device)]
14centered_billing = [a - m for a, m in zip(billing, mean_direction)]
15centered_device = [a - m for a, m in zip(device, mean_direction)]
16centered_similarity = cosine(centered_billing, centered_device)
17
18print("raw cosine:", round(raw_similarity, 3))
19print("centered cosine:", round(centered_similarity, 3))1raw cosine: 0.98
2centered cosine: -1.0The output makes the warning concrete: raw cosine is 0.98, while centered cosine is -1.0. Centering isn't a universal production recipe. Use it as a diagnostic, then measure retrieval or classification quality on held-out examples instead of trusting a similarity score in isolation.
Match the representation to the failure
Choose a representation from the failure mode and the measurement that could prove an improvement. No representation wins by name alone.
| Need | Useful starting point | What to measure |
|---|---|---|
| Small fixed vocabulary, simple classifier | Trainable embedding lookup | Held-out classification quality and latency |
Rare spelling variants such as tokenized | Character or subword-aware static vectors | Recall on unseen variants |
Ambiguous words such as billing/device charge | Contextual token states | Accuracy on sense-dependent cases |
| Search over whole documentation chunks | Sentence or chunk embedding model | Retrieval precision and recall on real queries |
Contextual token states aren't automatically good message-level retrieval vectors. Pooling and task training change what a vector means, so evaluate those choices on the retrieval task.
Later retrieval lessons pick them up.
The same geometry feeds next-token prediction. A model scores candidate tokens from the current state, then turns those scores into probabilities. The next question is how well those probabilities fit text held out from training.
Mastery check
What strong answers show
- Foundational: You can explain why an embedding lookup returns one stored row for every occurrence of a token ID.
- Intermediate: You can build a co-occurrence or prediction example and show why the two meanings of
chargerequire context mixing. - Advanced: You can choose a representation for a measured failure case and test whether cosine geometry supports the chosen metric.
Follow-up questions
Why can't a static embedding table represent billing charge and device charge differently in these two sentences?
Answer
The tokenizer emits the same token ID for both occurrences, and a static lookup retrieves one stored row per ID. Only a later context-dependent computation can use words such as invoice or scanner to separate the senses.
What does a FastText-style character n-gram representation fix for tokenized, and what does it not fix for charge?
Answer
Character n-grams let an unseen spelling such as tokenized reuse pieces learned from related forms such as tokenize. They don't decide whether an already-known spelling such as charge means a bill or a powered device in a particular sentence.
Why should a retrieval team test cosine similarity on held-out queries before trusting contextual vectors?
Answer
Contextual vectors can share dominant directions, so unrelated token states may receive high raw cosine scores. Held-out retrieval or classification examples reveal whether the geometry supports the product decision.
When embeddings mislead
- Treating token IDs as meaning: An ID only selects a row. Training and context create useful geometry.
- Calling subword handling disambiguation: Character pieces help unfamiliar spellings, not multiple senses of one familiar word.
- Trusting cosine without an evaluation set: A high score can come from shared directions rather than task-relevant similarity.