The problem in brief
You take a short English text (three paragraphs about a seaside town), split it into lowercase tokens, and look up each distinct token in the pretrained GloVe embeddings, where every word is a vector of 100 numbers. After normalising the vectors you build the matrix of all pairwise cosine similarities, use it to find each word's closest neighbour, and then study with linear algebra: is it invertible, how does its eigen-decomposition relate to the SVD of the embedding matrix, what is its Frobenius norm, and how well can it be approximated with far fewer numbers. Fifteen short parts mix explanation, NumPy one-liners and proofs, with strict rules (no loops, no @, no np.linalg except for the SVD).
How you are scored
90 points over 15 parts (5 or 10 each). Code parts are checked by running them; "reasoning required" parts need a proof. Here is a summary of our answers, all checked by solution.py:
| part | answer |
|---|---|
| 5.1 | 225 occurrences, then 158 distinct tokens |
| 5.5 | |
| 5.6 | |
| 5.8 | all diagonal entries equal 1 (up to float32 rounding, within ) |
| 5.9 | sim_dict["bright"] == "lights" reproduced |
| 5.10 | not invertible: |
| 5.12 | , |
| 5.13 | |
| 5.14 | , , error |
First look at the data
The text has 225 tokens, all of which happen to be in GloVe's 400 000-word vocabulary, and 158 distinct ones. It was clearly written with groups of near-synonyms (walk, stroll, wander; happy, cheerful, pleased; tired, weary), so nearest neighbours should be meaningful. One surprise appears as soon as is drawn: 98 % of the off-diagonal similarities are positive (mean 0.34, minimum only , for "conversation" and "seabirds"). GloVe vectors are not spread evenly around the origin; they share a common direction. This will explain why the low-rank approximation works so well.

Question by question
5.1 and 5.2: the two lengths and set
The first print(len(tokens)) counts every occurrence of every token that GloVe knows (225, repeated words such as "the" or "and" counted each time). tokens = list(set(tokens)) then builds a set, which keeps one copy of each distinct token, and converts it back to a list: 158 tokens. That is the answer to 5.2 too, with one caveat worth stating: a set has no order, so the order of the resulting list is arbitrary (it can change between Python runs). Our script sorts the list afterwards so that row of every matrix always refers to the same word.
5.3 and 5.4: the matrices
vectors_arr = np.array(vectors) # (N, 100)
W = vectors_arr / np.sqrt((vectors_arr ** 2).sum(axis=1, keepdims=True))
keepdims=True makes the norms a column of shape , so broadcasting divides each row by its own norm.
5.5: the range
is the cosine of the angle between two unit vectors. By Cauchy-Schwarz, , so : 1 for the same direction, for opposite directions, 0 for orthogonal ones.
5.6 and 5.7: from
Entry of is row of dotted with row of , which is exactly . So . Without @:
S = np.einsum("ik,jk->ij", W, W) # or: S = np.dot(W, W.T)
5.8: the diagonal
print(np.diag(S)) shows values all equal to 1 (in float32 they range from 0.9999996 to 1.0000002). Interpretation: , every word is perfectly similar to itself. This is a sanity check on the normalisation, and also a warning for the next part: the most similar token to any word is always itself.
5.9: nearest neighbours
So we mask the diagonal before taking the row-wise maximum:
S_off = S.copy()
np.fill_diagonal(S_off, -np.inf)
sim_dict = dict(zip(tokens, [tokens[j] for j in S_off.argmax(axis=1)]))
We get "bright": "lights" as the statement promises, and the neighbours are convincingly semantic: happy pleased (0.74), walk stroll, tired weary, talked spoke, night evening (0.84), bread baked, boats fishermen. Note the last two: embeddings capture words that occur in the same contexts, not only synonyms.
5.10: is invertible?
No. with . Every column of is times a vector, so it lies in the column space of , which has dimension at most 100. Hence
An matrix of rank less than is singular. Concretely, there is a nonzero with (the system has more unknowns than equations), and then . In general: a Gram matrix of vectors in can be invertible only if .
5.11 and 5.12: SVD of and spectral decomposition of
U, sigma, Vt = np.linalg.svd(W, full_matrices=True) # U: N x N, sigma sorted decreasing
V = Vt.T
Substituting :
since . is orthonormal and is an diagonal matrix with entries followed by zeros. This is a spectral decomposition, already sorted in decreasing order:
Q = U
lam = np.concatenate([sigma ** 2, np.zeros(N - len(sigma))])
full_matrices=True matters: with the economy SVD, would be only and not a square orthonormal . (The 58 zero eigenvalues are another proof of 5.10.)
5.13: the Frobenius norm
The Frobenius norm satisfies , and it is unchanged by orthonormal factors. Using 5.12 and the cyclic property of the trace:
So . Numerically both sides give 60.8046. A bonus identity: , because the diagonal of is all ones; indeed .
5.14: the best approximation with numbers
Keep the largest singular directions:
By the same trace argument, and
Why this is the minimum: any has rank at most , and by the Eckart-Young theorem the closest rank- matrix to in Frobenius norm is obtained by keeping its largest eigen-components, which is exactly our . Note that is not "the first coordinates of the embeddings": that naive choice (renormalised) gives a relative error of 7.3 at and still 0.32 at , against 0.088 and 0.0006 for the SVD.
5.15: the plot

The key idea
A similarity matrix is a Gram matrix, so everything about it is governed by the SVD of the data matrix. immediately gives its rank (at most 100, hence singular), its eigenvectors (the left singular vectors of ), its eigenvalues (), its norm () and its best compressions (truncate the SVD).
The plot also tells a story about GloVe. A single rank-one term already captures 91 % of . That first direction is almost exactly the average word vector (cosine 0.997 between the top right-singular vector and the mean of the rows of ): all words share a common component, which is why nearly all similarities are positive. This "anisotropy" is common in embeddings, and subtracting the mean vector before computing similarities is a standard fix when one wants the remaining, more semantic directions to dominate. Because the error is measured with fourth powers of , this one dominant eigenvalue weighs enormously; reaching 1 % error needs and 5 % needs .
The full solution
solution.py follows the starter code (only its imports), sorts the token list for reproducibility, and checks every claim with an assertion: , and , the Frobenius identity, and the error formula of 5.14 against direct computation of for several . The plotting part of 5.15 is:
err = [(sigma[r:] ** 4).sum() / (sigma ** 4).sum() for r in range(1, 101)]
plt.plot(range(1, 101), err); plt.xlabel("r"); plt.ylabel("relative squared Frobenius error")
Results
| check | value |
|---|---|
| tokens / distinct tokens | 225 / 158 |
| diagonal of (float32) | 1 within |
sim_dict["bright"] | "lights" |
| 100, so () is singular | |
| vs | 60.8046 vs 60.8046 |
| relative error, best rank 1 / 5 / 10 / 20 / 50 | 0.088 / 0.037 / 0.019 / 0.0073 / 0.0006 |
| same with naive "first coordinates" | 7.29 / 1.86 / 0.99 / 0.61 / 0.32 |
| smallest for error 5 % / 1 % | 4 / 17 |
What to remember
- Cosine similarity of normalised rows is one matrix product: ; mask the diagonal before searching for nearest neighbours.
- A Gram matrix of vectors in has rank at most , so it is singular whenever .
- The SVD of hands you the eigen-decomposition of : same left vectors, squared singular values (and use
full_matrices=Trueif you need a square ). - Frobenius norms and low-rank errors are sums over singular values, and truncating the SVD is optimal (Eckart-Young); cutting coordinates is not.
- Look at the spectrum: one huge eigenvalue means a shared component (here, the mean word vector), which inflates all similarities.