How does a computer understand what a word means? A computer only processes numbers, matrices and linear algebra β€” it cannot read letters or sentences directly.

This lesson walks through digitising text properly: breaking sentences into token codes (tokenization), getting past the out-of-vocabulary trap, and on to representing meaning as vectors in a high-dimensional space (word embeddings). We also cover the mathematics of cosine similarity and build a synonym-finding application, solving semantic similarity with PyTorch.

βœ… What you need before starting
Libraries: pip install torch. The project at the end downloads nothing and needs no network.

Knowledge you need: Lesson 2 β€” the dot product and cosine similarity. Section 8.3 reuses that exact formula, only comparing 300-dimensional vectors instead of 3-dimensional ones. If "a vector is an arrow in space" still feels unfamiliar, reread section 2.1 first.

8.1 From writing to numbers: tokenization

Before feeding text into a machine learning model, the raw text must go through a mandatory preprocessing step called tokenization. This is the process of cutting a long character string into smaller units of information, called tokens (single characters, syllables, words or phrases).

There are three main tokenizer designs:

  • Character-level tokenization: splits text down to individual letters (a, b, c...).
    Advantage: an extremely compact vocabulary (a few hundred characters), and it never fears an unknown word.
    Drawback: the computer has to process very long sequences, and neurons struggle to learn a word's overall meaning because the information is shattered at the letter level.
  • Word-level tokenization: splits on whitespace into whole words.
    Advantage: preserves each word's original meaning well.
    Drawback: vocabulary explosion (up to millions of words). In particular, on meeting a word never seen during training, the model hits the out-of-vocabulary (OOV) problem and must replace that word with a placeholder [UNK] (unknown), losing the sentence's information entirely.
  • Subword-level tokenization: the gold standard for today's large language models such as GPT and BERT, using algorithms like BPE (byte pair encoding) or WordPiece.
    How it works: keeps common words whole, but cuts rare or compound words into meaningful prefix/suffix fragments (the word "embeddings" might become "embed" and "##dings"). This keeps the vocabulary at an optimal size (roughly 30,000–50,000 tokens) while still representing any new word by assembling fragments, eliminating OOV entirely.
⚠️ Pitfall: a tokenizer mismatch between training and inference
Every tokenizer has its own vocabulary of integer identifiers (token IDs), generated independently. If you train a model with tokenizer A but run inference with tokenizer B β€” even though both split the same sentence β€” the output will be complete nonsense. Always save the tokenizer alongside the model's weight file, to guarantee the IDs stay consistent.
🧠 The BPE algorithm, step by step by hand
There is nothing mysterious about BPE β€” it just repeats one rule: find the most frequent pair of adjacent symbols across the whole corpus and merge them into a new symbol. With a toy corpus of three words, "low" (frequency 5), "lowest" (frequency 2) and "newest" (frequency 6) β€” each pre-split into individual characters plus an end-of-word marker </w> β€” the algorithm runs like this (verified by running actual Python):
  • Step 1: the pair (w, e) is the most frequent (2 times in "lowest" + 6 times in "newest" = 8) β†’ merge into we.
  • Step 2: the pair (we, s) appears 8 times β†’ merge into wes.
  • Step 3: the pair (wes, t) appears 8 times β†’ merge into west.
  • Step 4: the pair (west, </w>) appears 8 times β†’ merge into west</w>, producing one complete subword token shared by both "lowest" and "newest".
After 4 merges the subword vocabulary has learned by itself that "west" is a shared suffix, while "low" (the highest-frequency word, but not part of the most common pair) stays as loose characters β€” exactly the "split rare words, keep common words whole" behaviour described above.

An important detail if you implement BPE yourself: at step 1 there are four tied pairs, all appearing 8 times β€” (w,e), (e,s), (s,t) and (t,</w>). The example above picks (w,e), but that is an arbitrary tie-break, not the single correct answer. Implementing BPE with a different tie-breaking order gives a different merge sequence and a different subword set β€” and is still perfectly valid. Know this so you don't assume your implementation is wrong when it disagrees with this article.

8.2 The semantic map: word embeddings

Once the tokenizer has turned a sentence into a list of integer IDs: $$\text{"I love AI"} \to [102, 540, 891]$$ how do we carry that information into a neural network?

The simplest approach is one-hot encoding: create a vector as long as the entire vocabulary $V$, put a 1 at the word's ID position and 0 everywhere else. But this has two fatal weaknesses:

  1. An extremely wasteful sparse matrix: with a vocabulary of $50{,}000$ words, every single word becomes a $50{,}000$-dimensional vector of almost all zeros.
  2. Total loss of semantic relationship: any two one-hot vectors $v_a$ and $v_b$ are always orthogonal in vector space, so their dot product is zero: $$v_a \cdot v_b = 0$$ Which means the computer treats "cat" and "dog" as entirely unrelated β€” exactly as unrelated as "cat" and "table".
🧠 What word embeddings really are, and the nn.Embedding layer
Word embeddings solve this by projecting word IDs into a dense vector space of a fixed, much smaller number of dimensions (typically $d = 300$, $768$ or $1536$).

In PyTorch, nn.Embedding(num_embeddings, embedding_dim) is really a giant lookup table holding a trainable weight matrix of size $V \times d$.

When you feed in an integer token ID $k$, the layer performs no complex matrix multiplication at all β€” it simply retrieves and returns row $k$ of the weight matrix. That operation is extremely fast ($\mathcal{O}(1)$) and mathematically equivalent to multiplying a one-hot vector by the embedding layer's weight matrix: $$\text{Embedding}(k) = \text{OneHot}(k) \times W_{\text{embed}}$$

Because it is trained simultaneously on an enormous body of text, the embedding matrix automatically shifts each word's coordinates so that: words appearing in similar contexts end up near each other in the high-dimensional space. That is the premise behind the classic semantic-similarity arithmetic: $$\text{Embedding("King")} - \text{Embedding("Man")} + \text{Embedding("Woman")} \approx \text{Embedding("Queen")}$$

8.3 Measuring semantic distance: cosine similarity

With words represented as vectors in a high-dimensional space, how do we measure how semantically similar they are?

We cannot use ordinary geometric distance (Euclidean, L2) for a direct comparison, because Euclidean distance is sensitive to a vector's length (its norm). In sentence-comparison tasks a long sentence can produce aggregate vectors of very large magnitude, throwing Euclidean distance far off compared with a short sentence even when both share the same topic.

Instead we measure the angle between two vectors, with the formula for cosine similarity:

πŸ“ The cosine similarity formula
The cosine similarity of two high-dimensional vectors $A$ and $B$ is the dot product divided by the product of their L2 norms: $$\text{Cosine Similarity}(A, B) = \cos(\theta) = \frac{A \cdot B}{\|A\|_2 \|B\|_2} = \frac{\sum_{i=1}^{d} A_i B_i}{\sqrt{\sum_{i=1}^{d} A_i^2} \sqrt{\sum_{i=1}^{d} B_i^2}}$$ Where:
  • The result lies in the range $[-1, 1]$.
  • A result of $1$: the vectors point in exactly the same direction (angle $0^\circ$), indicating absolute similarity.
  • A result of $0$: the vectors are orthogonal ($90^\circ$), with no semantic relationship.
  • A result of $-1$: the vectors point in exactly opposite directions ($180^\circ$).
⚠️ Pitfall: confusing semantic similarity with synonymy
A high cosine similarity between two embeddings only shows that they tend to appear near each other in the same distributional context (the distributional hypothesis).

For example: two complete opposites such as "hot" and "cold" typically score extremely high on cosine, because both appear in sentences about weather and temperature. Keep this in mind when designing real synonym filters.

8.4 Hands-on project: a semantic space built by hand

This lesson's project takes a keyword, turns it into an embedding vector, then computes cosine similarity against the whole vocabulary to find the closest words in meaning β€” including the word-algebra operation king βˆ’ man + woman.

⚠️ The vectors here are WRITTEN BY HAND, not learned by a machine
This needs saying plainly, because it is the easiest thing in the lesson to misread. The embedding matrix in the script is not trained β€” 9 four-dimensional vectors are typed out, with each dimension given a meaning by a human: dimension 0 is "royalty", 1 is "gender", 2 is "technology", 3 is "drinks".

So this is NOT Word2Vec. Word2Vec is a training algorithm: it reads billions of sentences and discovers those numbers itself, with nobody telling it what any dimension means. This script skips the entire training half and uses only what comes after β€” lookup and angle measurement.

Why it is still worth doing this way: with real 300-dimensional vectors learned from a corpus, nobody can read what dimension 174 means β€” it is a black box. Here you can see every dimension, so when king βˆ’ man + woman produces "queen" you understand why it does, rather than only seeing that it did. The price is that the numbers come out artificially clean, and the section below points out exactly where they are too clean.

Training a real embedding needs a loop like Lesson 6's over millions of word-context pairs; Lessons 10 and 14 will use embeddings already trained by real models.
word_similarity.py
# word_similarity.py
# Lesson 8: Text processing & word embeddings
# Practical AI Engineer series
#
# Run it with:  python word_similarity.py
# Requires:     pip install torch
#
# IMPORTANT β€” THE VECTORS BELOW ARE HAND-WRITTEN, NOT TRAINED.
# This is not Word2Vec. Word2Vec is a training algorithm that reads billions of
# sentences and discovers these numbers on its own. Here the 9 vectors are typed
# out by hand, with each of the 4 dimensions given a meaning by a human, so that
# you can SEE why the geometry works. A real 300-dimensional trained embedding is
# opaque: nobody can say what dimension 174 means.
#
# The cost of that clarity is that the numbers come out artificially clean β€” see
# the note on the 1.0000 score at the bottom of this file.

import torch
import torch.nn as nn

vocab = {
    'king': 0,
    'queen': 1,
    'man': 2,
    'woman': 3,
    'computer': 4,
    'programming': 5,
    'artificial_intelligence': 6,
    'coffee': 7,
    'tea': 8,
}
inverse_vocab = {v: k for k, v in vocab.items()}

# Dimension 0: royalty Β· 1: gender (positive male, negative female)
# Dimension 2: technology Β· 3: drinks
embedding_weights = torch.tensor(
    [
        [1.0, 0.9, 0.0, 0.0],  # king
        [1.0, -0.9, 0.0, 0.0],  # queen
        [0.0, 1.0, 0.0, 0.0],  # man
        [0.0, -1.0, 0.0, 0.0],  # woman
        [0.0, 0.0, 1.0, 0.0],  # computer
        [0.0, 0.0, 0.9, 0.0],  # programming
        [0.0, 0.1, 1.0, 0.0],  # artificial_intelligence
        [0.0, 0.0, 0.0, 1.0],  # coffee
        [0.0, 0.0, 0.0, 0.9],  # tea
    ],
    dtype=torch.float32,
)

vocab_size, embedding_dim = embedding_weights.shape
embed = nn.Embedding(num_embeddings=vocab_size, embedding_dim=embedding_dim)
# Fixed weights, never trained further.
embed.weight = nn.Parameter(embedding_weights, requires_grad=False)


def cosine_similarity(vector_a, matrix_b):
    """Cosine of the angle between one vector (1,d) and every row of (V,d)."""
    dot_product = torch.sum(vector_a * matrix_b, dim=1)
    norm_a = torch.norm(vector_a, p=2, dim=1)
    norm_b = torch.norm(matrix_b, p=2, dim=1)
    # +1e-8 guards against dividing by zero for an all-zero vector.
    return dot_product / (norm_a * norm_b + 1e-8)


def find_most_similar(target_word, top_n=3):
    if target_word not in vocab:
        print(f"'{target_word}' is not in the vocabulary.")
        return

    target_vector = embed(torch.tensor([vocab[target_word]]))
    scores = cosine_similarity(target_vector, embed.weight)
    top_scores, top_indices = torch.topk(scores, k=len(vocab))

    print(f"--- closest words to '{target_word}':")
    shown = 0
    for score, idx in zip(top_scores.tolist(), top_indices.tolist()):
        word = inverse_vocab[idx]
        if word == target_word:
            continue  # a word is always its own closest match; skip it
        print(f'  {shown + 1}. {word:<24} cosine {score:.4f}')
        shown += 1
        if shown >= top_n:
            break
    print()


def analogy(a, b, c, top_n=2):
    """Solve 'a is to b as c is to ?' β€” the classic king - man + woman."""
    vec = embed(torch.tensor([vocab[a]])) - embed(torch.tensor([vocab[b]])) + embed(torch.tensor([vocab[c]]))
    scores = cosine_similarity(vec, embed.weight)

    print(f'=== word analogy: {a} - {b} + {c}')
    shown = 0
    for score, idx in zip(*[t.tolist() for t in torch.topk(scores, k=len(vocab))]):
        word = inverse_vocab[idx]
        # Exclude the three input words. With hand-made vectors this barely
        # matters, but with REAL trained embeddings the input word almost always
        # ranks first, and forgetting to exclude it is the classic mistake that
        # makes analogy code look broken.
        if word in (a, b, c):
            continue
        print(f'  {shown + 1}. {word:<24} cosine {score:.4f}')
        shown += 1
        if shown >= top_n:
            break
    print()


if __name__ == '__main__':
    find_most_similar('coffee', top_n=2)
    find_most_similar('computer', top_n=2)
    analogy('king', 'man', 'woman')

    # Why 'tea' scores exactly 1.0000 against 'coffee': their vectors are
    # [0,0,0,1] and [0,0,0,0.9] β€” exactly parallel, differing only in length,
    # and cosine ignores length. A real trained embedding never gives exactly
    # 1.0 for two different words. This is the artificial cleanliness that comes
    # with hand-writing the numbers.
    print('note: coffee and tea are exactly parallel by construction, hence 1.0000.')

Running it produces:

Terminal
--- closest words to 'coffee':
  1. tea                      cosine 1.0000
  2. man                      cosine 0.0000

--- closest words to 'computer':
  1. programming              cosine 1.0000
  2. artificial_intelligence  cosine 0.9950

=== word analogy: king - man + woman
  1. queen                    cosine 0.9950
  2. computer                 cosine 0.0000

note: coffee and tea are exactly parallel by construction, hence 1.0000.

The analogy works: king βˆ’ man + woman gives queen at 0.995. But the other three numbers are the part worth learning from, because they expose the limits of building this by hand.

πŸ•³οΈ Three numbers that are "too good", and what they really mean
1. The 1.0000 between "coffee" and "tea" does not mean they are perfect synonyms. The two vectors are [0,0,0,1] and [0,0,0,0.9] β€” exactly parallel, differing only in length, and cosine ignores length. With embeddings learned from real text, two different words never score exactly 1.0. Seeing 1.0 in a real project almost always means you are comparing a word with itself.

2. The 0.0000 between "coffee" and "man" is one-hot encoding coming back. The two vectors are orthogonal because the dimensions were assigned in completely separate slots β€” the very weakness of one-hot that section 8.2 just criticised. Real embeddings never give an absolute 0, because every word shares some context with every other.

3. Excluding the input words from analogy results is mandatory. In the code, analogy() skips king, man and woman. Here it barely changes anything (king only scores 0.005), but with real embeddings the input word almost always ranks first β€” and forgetting to exclude it is the classic mistake that makes analogy code look broken.

In short: building by hand lets you see the geometry, but do not take these numbers as the expectation for real data.

How to run this project on your own machine

  1. Install: pip install torch. No data download needed.
  2. Download word_similarity.py at the end of the lesson, or retype the code above.
  3. Run it: python3 word_similarity.py. There is no randomness, so the result is always identical.
  4. Then try three things, each teaching something:
    • Add a new word to vocab and a matching vector row β€” say 'prince': 9 with [1.0, 0.9, 0.0, 0.0]. It will score 1.0 against "king", because you just typed the same direction. That is the most direct way to feel that "meaning" here is decided by you.
    • Change "queen"'s gender dimension from -0.9 to +0.9 and rerun. The analogy stops producing "queen" β€” proof that the result comes from the geometry, not from the name.
    • Remove the if word in (a, b, c): continue line in analogy(). It barely changes the answer here, but remember this behaviour when you use real embeddings in Lesson 14.

Lesson summary & bridge to what's next

πŸ”‘ What you now have:
  • Achieved: the three tokenizer designs and why modern LLMs pick subwords β€” plus the fact that BPE can yield several different, equally valid subword sets depending on tie-breaking.
  • Achieved: why one-hot fails (every word orthogonal to every other), and that nn.Embedding is really just an $\mathcal{O}(1)$ lookup table.
  • Achieved: cosine similarity, and why it measures angle rather than distance β€” a vector's length says nothing about its meaning.
  • Achieved: telling using an embedding apart from training one. This lesson's project does the first; Word2Vec is the second, and this lesson does not do it.
  • Achieved: reading cosine scores critically β€” an absolute 1.0 or an absolute 0.0 both signal artificial data rather than good semantics.

Bridge to the next lesson: individual word vectors cannot express the order of a sentence. To handle long language sequences over time we need recurrent networks and the attention mechanism, in Lesson 9.

Download the hands-on code for this lesson

The Python file word_similarity.py β€” builds the embedding matrix, defines the vocabulary and computes cosine similarity, including the word-analogy operation (run python word_similarity.py, needs torch):

Download word_similarity.py

πŸ“– Further reading

Related lessons in this series

Lesson 7: Computer vision basics & convolutional networks Lesson 9: Recurrent networks (RNN) and the rise of attention Back to the Practical AI Engineer roadmap

Comments