Tutorial 1: PyTorch and Word Embeddings

Build a neural network twice — once from raw tensors, once with nn.Module — and then learn word vectors with Skip-gram Word2Vec and compare them with GloVe and FastText.

📅 Sep. 18th, 2026 📚 Companion to Lecture 2 ⏱ about 2–2.5 hours 💻 Colab (GPU) recommended 📝 2 notebooks
Open Notebook 1 in Colab Open Notebook 2 in Colab ⬇ Download all (.zip) Lecture 2 slides

1Overview

Lecture 2 introduced the idea that a word can be represented by a dense vector learned from the contexts it appears in. This tutorial turns that idea into working code. Notebook 1 gives you the PyTorch skills you will use all semester: tensors, automatic differentiation, optimizers, nn.Module and data loaders. Notebook 2 uses those skills to train a Skip-gram Word2Vec model on 50,000 Quora questions and to compare it with pre-trained GloVe and FastText vectors.

Tensorsrequires_gradAutogradbackward()OptimizerSGD / Adamnn.ModuleLinear, EmbeddingDataLoadermini-batchesSkip-gramtrain vectorsGloVe & FastTextcompareNotebook 1: PyTorch foundationsNotebook 2: word embeddings
The learning path of this tutorial: each step reuses what the previous one built.

Learning objectives

  • Write the forward pass of a 4-layer feed-forward network with raw tensors, and explain what loss.backward() computes.
  • Verify that optimizer.step() performs exactly the update \(W \leftarrow W - \eta\,\nabla_W J\), and why optimizer.zero_grad() is needed.
  • Re-implement the same network as an nn.Module and feed it data with Dataset/DataLoader.
  • Turn raw text into Skip-gram training pairs (tokenize, build a vocabulary, slide a context window).
  • Train a Skip-gram model, read off its word vectors, and query nearest neighbours by cosine similarity.
  • Explain how GloVe and FastText differ from Word2Vec, and estimate their memory and compute cost.

Time plan

PartWhat you doTime (approx.)
SetupOpen Colab, switch to a GPU runtime, run the install cell10 min
Notebook 1Raw-tensor network → autograd → nn.Module → data pipelines45 min
Notebook 2Data preparation → Skip-gram training (≈ 2 min on a T4 GPU) → GloVe/FastText → comparison60 min
Wrap-upExercises and the self-check quiz20–30 min

Downloads add time: CIFAR-10 (170 MB) in Notebook 1, and the pre-trained GloVe (376 MB) and FastText (958 MB) vectors in Notebook 2.

2Before you start

Option A · Google Colab (recommended)

  1. Click Open in Colab for a notebook, then File → Save a copy in Drive so your edits are kept.
  2. Runtime → Change runtime type → T4 GPU. Notebook 2 calls model.cuda() and needs a GPU.
  3. Run cells top to bottom with Shift+Enter. The first cell of Notebook 2 installs packages and downloads the Quora data.

Option B · Run locally

conda create -n nlp26 python=3.11 -y
conda activate nlp26
pip install torch torchvision numpy pandas matplotlib \
            scikit-learn tqdm gensim "nltk>=3.9.2,<4" "bokeh>=3.8,<3.10" jupyterlab
jupyter lab

No NVIDIA GPU? Replace .cuda() with .to(device), where device = "cuda" if torch.cuda.is_available() else "cpu". Training on a CPU takes several times longer.

What you need to know already

  • Python basics (functions, classes, list comprehensions) and NumPy arrays.
  • Matrix–vector products and the chain rule from calculus.
  • Lecture 2: distributional semantics, one-hot vs. dense vectors, cosine similarity.

3Notebook 1 · From raw tensors to nn.Module

Notebook 1 at a glance

torchtorchvisionnumpy75 cellsGPU optional

You build a small regression network by hand — every weight is a tensor you create — and let PyTorch's autograd compute the gradients. Then you rebuild the same network the idiomatic way and learn how PyTorch feeds data to a model.

3.1 The network and its forward pass

The network maps a 3-dimensional input to one output through three hidden layers of 5 units, with a sigmoid after every layer (\(D = 3\), \(K = 5\)). Its 86 parameters are created by hand with requires_grad=True, which tells PyTorch to track every operation on them.

x (D=3)a₁ = σ(z₁)a₂ = σ(z₂)a₃ = σ(z₃)ŷ = σ(z₄)W1 (5×3), b1W2 (5×5), b2W3 (5×5), b3W4 (1×5), b4J = (ŷ − y)²forward: compute activationsbackward: J.backward() fills .grad for every W and b (chain rule)
The 3 → 5 → 5 → 5 → 1 network of Notebook 1. Forward: compute activations left to right. Backward: send gradients of the loss right to left.
$$a_0 = x,\qquad z_\ell = W_\ell\, a_{\ell-1} + b_\ell,\qquad a_\ell = \sigma(z_\ell)=\frac{1}{1+e^{-z_\ell}},\qquad \ell = 1,\dots,4$$ $$\hat y = a_4,\qquad J = (\hat y - y)^2$$
Notebook 1 · forward pass with raw tensors
D, K = 3, 5
W1 = torch.randn((K, D), requires_grad=True)   # parameters you create yourself
b1 = torch.randn((K, 1), requires_grad=True)
# ... W2, b2, W3, b3 (5x5), W4 (1x5), b4

def sigmoid(x):
    return 1 / (1 + torch.exp(-x))

x = torch.randn((D, 1))                         # one input
y_ground = torch.randn((1,))                    # its target
a1 = sigmoid(torch.matmul(W1, x) + b1)
a2 = sigmoid(torch.matmul(W2, a1) + b2)
a3 = sigmoid(torch.matmul(W3, a2) + b3)
y_pred = sigmoid(torch.matmul(W4, a3) + b4)
J = (y_pred - y_ground) ** 2                    # squared error
Why the sigmoid? Without a nonlinearity, the four layers collapse into one linear map: \(W_4W_3W_2W_1x + \text{const}\). The nonlinearity is what lets depth add expressive power.

3.2 Backward pass: who is responsible for the loss?

J.backward() walks the recorded computation graph backwards and applies the chain rule, storing \(\partial J/\partial \theta\) in .grad for every tracked parameter. For the last layer, for example:

$$\frac{\partial J}{\partial z_4} = 2(\hat y - y)\,\hat y\,(1-\hat y),\qquad \frac{\partial J}{\partial W_4} = \frac{\partial J}{\partial z_4}\,a_3^{\top},\qquad \frac{\partial J}{\partial a_3} = W_4^{\top}\frac{\partial J}{\partial z_4}$$

and the same pattern repeats layer by layer. The notebook shows this live:

print(W2.grad) # before backward None
J.backward() print(W2.grad) # after backward tensor([[ 0.0226, 0.0109, ...], [ 0.0302, 0.0147, ...], ...])

Gradients do not exist until a backward pass creates them — that is why the first print shows None.

3.3 Updating the parameters

Gradient descent moves every parameter a small step against its gradient, \(\theta \leftarrow \theta - \eta\,\nabla_\theta J\) with learning rate \(\eta = 0.1\). The notebook first computes the new \(W_2\) by hand, then lets torch.optim.SGD do it, and checks that both agree:

Notebook 1 · manual update vs. optimizer.step()
lr = 0.1
expected_new_W2 = W2 - lr * W2.grad             # the update, by hand

optimizer = torch.optim.SGD([W1, b1, W2, b2, W3, b3, W4, b4], lr=lr)
optimizer.step()                                # the same update, by PyTorch

torch.eq(W2, expected_new_W2).all()             # -> tensor(True)
Always reset gradients PyTorch adds new gradients to .grad. If you skip optimizer.zero_grad(), the next step uses the sum of the old and new gradients. The notebook shows that after zero_grad() every .grad is back to None.

3.4 The same network, the PyTorch way

nn.Linear owns its weight and bias, nn.Module registers them automatically, and network.parameters() hands them all to the optimizer. Compare the two versions:

raw tensors
W1 = torch.randn((K, D), requires_grad=True)
b1 = torch.randn((K, 1), requires_grad=True)

z1 = torch.matmul(W1, x) + b1
a1 = sigmoid(z1)
nn.Module (condensed from the notebook)
class neural_network(torch.nn.Module):
    def __init__(self, D, K):
        super().__init__()
        self.layer1 = torch.nn.Linear(D, K)
        self.layer2 = torch.nn.Linear(K, K)
        self.layer3 = torch.nn.Linear(K, K)
        self.layer4 = torch.nn.Linear(K, 1)

    def forward(self, x):
        a = torch.sigmoid(self.layer1(x))
        a = torch.sigmoid(self.layer2(a))
        a = torch.sigmoid(self.layer3(a))
        return torch.sigmoid(self.layer4(a))

A full training step is now four lines, the pattern you will reuse in every assignment:

one training step
network = neural_network(D, K)
optimizer = torch.optim.SGD(network.parameters(), lr=0.1)

y_pred = network(x)                 # 1. forward
J = (y_pred - y_ground) ** 2        # 2. loss
J.backward()                        # 3. backward
optimizer.step()                    # 4. update
optimizer.zero_grad()               #    and reset gradients for the next step

3.5 Feeding data: NumPy, Dataset and DataLoader

torch.from_numpy and .numpy() convert between NumPy and PyTorch without copying memory. For real datasets, a Dataset says how to get one example and a DataLoader turns it into shuffled mini-batches:

Notebook 1 · a custom Dataset and DataLoader
class CustomDataset(torch.utils.data.Dataset):
    def __init__(self):
        self.inputs = torch.randn(100, 3)            # 100 samples, 3 features
        self.labels = torch.randint(0, 2, (100,))    # binary labels

    def __getitem__(self, index):                    # one example
        return self.inputs[index], self.labels[index]

    def __len__(self):                               # dataset size
        return len(self.inputs)

train_loader = torch.utils.data.DataLoader(CustomDataset(), batch_size=64, shuffle=True)
inputs, labels = next(iter(train_loader))
Dataset size: 100 Input batch shape: torch.Size([64, 3]) Label batch shape: torch.Size([64])

The notebook also loads CIFAR-10 through torchvision.datasets: each image is a [3, 32, 32] tensor, and a loader with batch_size=64 yields batches of shape [64, 3, 32, 32].

4Notebook 2 · Word embeddings: Word2Vec, GloVe and FastText

Notebook 2 at a glance

torchgensimnltkscikit-learnbokeh53 cellsGPU required as written

Train Skip-gram Word2Vec from scratch on Quora questions, inspect what it learned, then compare it with pre-trained GloVe and FastText vectors on similarity, rare words and cost.

4.1 From raw text to training pairs

Quora questionsfirst 50,000 uniqueTokenize + lower-case621,721 tokensVocabulary7,251 words (count ≥ 5)Context windowsize 2 on each sideBatches128 (center, context) pairsTraining12,289 batches / epoch
Data preparation in Notebook 2, with the numbers from the provided run.

Words seen fewer than 5 times are mapped to <unk> (index 0); in the run this affects 36,778 of the 621,721 tokens (5.9%). The window of size 2 then turns every position into a (center, context) example:

whatt−2ist−1thetstept+1byt+2stepguidetoinvest…window size 2 on each side(the, what)(the, is)(the, step)(the, by)training pairs (center → context): the model learns to predict each context word from “the”
The first question in the corpus. With window size 2, the center word the has the context what, is, step, by — exactly contexts[2] in the notebook.

4.2 Skip-gram Word2Vec

Skip-gram predicts each context word from the center word. Every word has two vectors: an input vector \(u_w\) (a row of the embedding matrix \(U\)) and an output vector \(v_w\) (a column of the output layer \(V\)). The model turns their dot products into a distribution over the vocabulary:

$$P(w_o \mid w_c) = \frac{\exp\!\left(v_{w_o}^{\top} u_{w_c}\right)}{\sum_{w=1}^{|V|} \exp\!\left(v_{w}^{\top} u_{w_c}\right)},\qquad \mathcal{L} = -\sum_{c}\ \sum_{-k \le j \le k,\ j \ne 0} \log P(w_{c+j}\mid w_c)$$
center wordindex of “the”nn.EmbeddingU: 7,251 × 32u_c32-d vectornn.LinearV: 32 × 7,251softmax over 7,251 wordsP(“step” | “the”)word vectors = rows of U (the embedding table); V is only needed for training
The Skip-gram model in the notebook: an nn.Embedding lookup followed by an nn.Linear layer, trained with cross-entropy against the true context word.
Notebook 2 · the Skip-gram model and its training loop (condensed)
class Skip_Gram_Model(nn.Module):
    def __init__(self, vocab_size, embedding_dim):
        super().__init__()
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)   # U
        self.out_layer = nn.Linear(embedding_dim, vocab_size)       # V

    def forward(self, inputs):                 # inputs: center-word indices
        return self.out_layer(self.embeddings(inputs))   # logits over the vocabulary

model = Skip_Gram_Model(len(word2index), 32).cuda()
criterion = nn.CrossEntropyLoss()              # softmax + negative log-likelihood
optimizer = optim.Adam(model.parameters(), lr=0.001)

for batch in make_skip_gram_batches_iter(contexts, window_size=2, num_skips=4, batch_size=128):
    tokens, labels = batch['tokens'].cuda(), batch['labels'].cuda()
    loss = criterion(model(tokens), labels)
    loss.backward(); optimizer.step(); optimizer.zero_grad()
Full softmax vs. negative sampling This implementation scores all 7,251 words for every training pair, which costs \(O(|V|\,d)\). That is fine for a small vocabulary. The original word2vec instead uses negative sampling: it scores the true context word and only \(K\) random “noise” words, reducing the cost to \(O(K\,d)\):
$$\log\sigma\!\left(v_{w_o}^{\top}u_{w_c}\right)+\sum_{i=1}^{K}\log\sigma\!\left(-v_{w_i}^{\top}u_{w_c}\right),\qquad w_i\sim P_n(w)\propto \text{count}(w)^{3/4}$$
Implementing it is one of the exercises below.

4.3 Training and what the model learned

SettingValueSettingValue
Embedding size \(d\)32OptimizerAdam, lr = 0.001
Window size2Context words per center word (num_skips)4
Batch size128 pairsEpochs4 (12,289 batches each)
Skip-gram training loss curve
Training loss from the notebook's log. It starts well below the uniform-guess level and flattens around 5.77: predicting an exact context word is genuinely hard.

The word vectors are the rows of model.embeddings.weight. Nearest neighbours by cosine similarity, \(\cos(u_a, u_b) = \frac{u_a^{\top}u_b}{\lVert u_a\rVert\,\lVert u_b\rVert}\), already show structure:

most_similar(embeddings_skipgram, index2word, word2index, 'my') ['my', 'our', 'your', 'joe', 'his', 'ebay', 'her', 'phone.', 'myself', 'hell']

Most neighbours of my are other possessives (our, your, his, her): they appear in the same contexts, so they get similar vectors. The notebook then draws an interactive t-SNE map of the 100 most frequent words with Bokeh.

4.4 GloVe: learning from global co-occurrence counts

Instead of predicting one context at a time, GloVe first counts how often every pair of words co-occurs in the whole corpus, \(X_{ij}\), and then fits vectors whose dot products match the log counts:

$$J = \sum_{i,j:\,X_{ij}>0} f(X_{ij})\left(w_i^{\top}\tilde w_j + b_i + \tilde b_j - \log X_{ij}\right)^2,\qquad f(x)=\begin{cases}(x/x_{\max})^{\alpha} & x \lt x_{\max}\\ 1 & \text{otherwise}\end{cases}$$

The weight \(f\) (with \(x_{\max}=100,\ \alpha=3/4\) in the paper) stops very frequent pairs such as “of the” from dominating. The notebook loads pre-trained 300-d vectors trained on Wikipedia + Gigaword (6B tokens):

Notebook 2 · loading GloVe (the last two lines are extra queries to try)
import gensim.downloader as api
glove_model = api.load("glove-wiki-gigaword-300")       # 376 MB, 400K words, 300-d
glove_model.most_similar("king", topn=5)                 # nearest neighbours
glove_model.similarity("king", "queen")                  # cosine similarity

4.5 FastText: words are bags of character n-grams

FastText represents a word by its own vector plus the vectors of its character n-grams (3 to 6 characters, with boundary markers < and >):

<where><whwhehererere><where>whole wordcharacter 3-grams (FastText uses n = 3 … 6) + the whole word; the word vector is their sumnote: the 3-gram “her” is different from the word <her>
The word where with \(n=3\), the example from the FastText paper. Its vector is the sum of the n-gram vectors and the whole-word vector.
$$u_{w} = \sum_{g\,\in\,\mathcal{G}_w} z_g$$

Because teach, teacher and teaching share n-grams, rare and inflected words get good vectors, and a trained FastText model can even build vectors for words it never saw.

Caveat: vectors vs. model api.load("fasttext-wiki-news-subwords-300") returns word vectors only (a KeyedVectors table of 1M words), not the n-gram model. A word missing from that table still raises a KeyError. To build vectors for unseen words, load the full binary model with gensim.models.fasttext.load_facebook_model(...).

4.6 Comparing the three methods

king-queen cosine similarity per model
Cosine similarity between king and queen, as printed by the notebook.

The gap is about data and scale, not about Skip-gram itself: our model saw 50K short questions in which king and queen are rare, with 32-d vectors and 4 epochs. GloVe and FastText were trained on billions of tokens with 300-d vectors. Likewise, Skip-gram returns None for serendipity because the word never made it into our 7,251-word vocabulary.

PCA of word vectors for three models
2-D PCA projections of king, queen, man, woman, prince, princess for each model (notebook output).
Reading this plot carefully Each model gets its own PCA fit, so positions are only comparable within one colour, not across models. Also, the Skip-gram vectors are 32-d and were zero-padded to 300-d before PCA, and a word missing from its vocabulary becomes an all-zero vector.

Memory and compute (example: \(|V| = 10^4,\ d = 300,\ T = 10^6\) tokens, window \(C = 5\), \(K = 5\) negatives, \(S = 10^5\) n-grams, \(N = 10^7\) co-occurring pairs; float32)

MethodWhat is stored during trainingMemoryTraining costStrength
Word2Vec (Skip-gram, neg. sampling)input + output vectors: \(2|V|d\)≈ 24 MB
12 MB kept after training
\(O(T\,C\,(K{+}1)\,d)\)simple, streaming, good semantics
GloVetwo vector sets + biases: \(2|V|d + 2|V|\), plus \(N\) nonzero counts≈ 64 MB
counts dominate
\(O(N\,d)\) per epochuses global statistics; stable
FastTextword + n-gram vectors: \((|V|+S)\,d\) (plus output vectors)≈ 132 MB Word2Vec cost × n-grams per wordrare words, morphology, OOV

Worked numbers: \(2\cdot10^4\cdot300\cdot4\,\text{B} = 24\,\text{MB}\); \((6.02\cdot10^6 + 10^7)\cdot 4\,\text{B} \approx 64\,\text{MB}\) (count values only; storing the \((i,j)\) indices adds more); \(1.1\cdot10^5\cdot300\cdot4\,\text{B} = 132\,\text{MB}\). Section 1.6 of the notebook counts only the input matrix for Word2Vec and leaves the co-occurrence entries out of its GloVe total, so its numbers are smaller.

5Pitfalls & troubleshooting

SymptomCause and fix
AssertionError: Torch not compiled with CUDA enabled or no CUDA GPUs are available Notebook 2 calls .cuda(). In Colab choose a GPU runtime; locally replace .cuda() with .to(device).
quora.zip is not a valid ZIP file Google Drive sometimes returns an HTML page instead of the file. Download train.csv from the Kaggle Quora Question Pairs page and upload it next to the notebook.
CIFAR-10 or GloVe/FastText downloads are very slow They are 170 MB, 376 MB and 958 MB. Run them once and keep the Colab session alive. You can skip the CIFAR-10 cells: the CustomDataset example teaches the same ideas without a download.
Colab runs out of RAM when loading FastText Restart the runtime and load only one of the two pre-trained models at a time, or del glove_model before loading FastText.
Loss does not go down / gradients grow Check that optimizer.zero_grad() is called every step and that the learning rate is not too large.
KeyError for a word The word is not in that model's vocabulary. Use word in word2index or word in glove_model.key_to_index before looking it up.
t-SNE plot looks different every run t-SNE is stochastic; pass random_state=42 to TSNE for a reproducible layout.

6Exercises

Not graded — but they are the best preparation for Assignment 1.

Warm-up · Notebook 1
Watch gradients vanish. Print the norm of W1.grad and W4.grad after one backward pass. Then replace the sigmoid with torch.relu and compare. Why are the first-layer gradients so much smaller with sigmoid? (Hint: \(\sigma'(z)\le 1/4\).)
Warm-up · Notebook 1
Train, don't just step. Fix one input x and target y_ground, run 200 steps of forward → loss → backward → step → zero_grad on the nn.Module network, and plot the loss. What happens if you remove zero_grad()?
Core · Notebook 2
Analogies. With GloVe, run glove_model.most_similar(positive=["woman", "king"], negative=["man"]). Try three analogies of your own (capitals, verb tenses, comparatives). Which ones fail, and why?
Core · Notebook 2
Scale up. Train Skip-gram on all questions instead of the first 50,000, and with embedding_dim = 100. How do the neighbours of my and the king–queen similarity change? Report training time as well.
Challenge
Implement negative sampling. Replace the full-softmax output layer with a second nn.Embedding for context vectors and the negative-sampling loss from Section 4.2 (sample \(K=5\) noise words from the unigram distribution raised to 3/4). Compare speed per epoch and the nearest neighbours with the full-softmax model.
Challenge
Real out-of-vocabulary vectors. Download a FastText .bin model, load it with load_facebook_model, and get vectors for made-up words such as unhuggable or tokenizerize. Which real words are their nearest neighbours?

7Self-check quiz

Click a question to reveal the answer.

1. Why does print(W2.grad) show None before J.backward()? Gradients are created by the backward pass. Until backward() runs, PyTorch has recorded the operations but has not computed any \(\partial J/\partial W_2\), so .grad is still empty.
2. What goes wrong if you never call optimizer.zero_grad()? backward() adds to .grad. Without resetting, each update uses the sum of all past gradients, so steps become too large and point in a stale direction.
3. Four linear layers without activations: how expressive is that network? No more than a single linear layer: \(W_4W_3W_2W_1x + c = W'x + c\). Nonlinear activations are what make depth useful.
4. In the notebook's Skip-gram model, which matrix are the word vectors? The weight of nn.Embedding (\(U\), shape \(|V| \times d\)). The output layer \(V\) holds the “context” vectors and is usually discarded after training.
5. The first logged loss is about 7.2. What would a model that guesses uniformly score, and what does that tell you? \(\ln 7251 \approx 8.89\). Even early in training the model is already better than chance, and the final 5.77 corresponds to an effective choice among roughly \(e^{5.77}\approx 320\) words — context prediction is inherently uncertain.
6. Why is Skip-gram's king–queen similarity only 0.03 while GloVe's is 0.63? Training data and capacity, not the algorithm: 50K Quora questions where king/queen are rare, 32-d vectors, 4 epochs — versus billions of Wikipedia/news tokens and 300-d vectors for the pre-trained models.
7. Can the FastText vectors loaded with api.load(...) produce a vector for an unseen word? No. That download is a plain table of word vectors. Only the full FastText model (with n-gram vectors, loaded via load_facebook_model) can compose a vector for a new word from its character n-grams.
8. What is the cost of one training pair with a full softmax, and how does negative sampling change it? Full softmax computes \(|V|\) dot products of size \(d\): \(O(|V|\,d)\). Negative sampling computes only \(K+1\) of them: \(O(K\,d)\), independent of vocabulary size.

8Further reading

Next steps: Lecture 2 slides · Course schedule (Assignment 1 is out).

Tutorial 1 · CSC5051/CSC6052/MDS5110 Natural Language Processing · School of Data Science, CUHK-Shenzhen · Fall 2026. Outputs and numbers on this page come from the provided notebook runs. ← Back to the course homepage