CSCE 471 Natural Language Processing · Spring 2027

Counting-Based Word Vectors

Kuan-Hao Huang · Course Website

Note: This page was created with the help of AI as a supplementary reading. It may contain errors or simplifications. The lecture slides are the primary reference for this course; please refer to them first.

How can a computer tell that great and excellent mean nearly the same thing, while great and giraffe do not? If every word is treated as an unrelated symbol, it can't. A model that learned “this movie was great” is positive would have no idea that “this movie was excellent” should be positive too.

This reading is about word vectors: giving every word its own vector so that words with similar meanings get similar vectors. We'll build those vectors with nothing but counting and a bit of linear algebra, following four steps: count which words appear near each other, measure similarity with cosine, reweight the counts with PPMI, and compress them into short dense vectors with SVD.

After reading this, you should be able to explain why one-hot vectors can't express similarity · state the distributional hypothesis · build a co-occurrence matrix · compute cosine similarity and PPMI by hand · explain what SVD does to word vectors.

1Why can't words just be symbols?

The simplest way to turn a word into a vector is a one-hot vector. Number the words in the vocabulary $\mathcal V$ from 1 to $|\mathcal V|$; word number $i$ becomes a vector of length $|\mathcal V|$ with a 1 in position $i$ and 0 everywhere else.

good = [0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0] great = [0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0] bad = [0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0]
Figure 1. One-hot vectors. Real vocabularies have 50,000 to 500,000+ words, so each vector is extremely long.

Imagine a search engine. A user types good restaurant, and we'd like to also return pages that say great restaurant. With one-hot vectors, the dot product of good and great is 0, exactly the same as the dot product of good and bad, or good and giraffe. Every pair of different words is orthogonal. One-hot vectors don't just encode similarity badly; they have no way to encode it.

A natural fix is to borrow knowledge from people. WordNet is a large hand-built thesaurus that lists synonyms, antonyms, and “is-a” relations (hypernyms) for English words. You can query it from Python:

from nltk.corpus import wordnet as wn
for s in wn.synsets("bad"):
    print(s.pos(), [l.name() for l in s.lemmas()])

# n ['bad', 'badness']
# s ['bad', 'spoiled', 'spoilt']
# a ['regretful', 'sorry', 'bad']
# s ['bad', 'risky', 'high-risk', 'speculative'] ...

If we add features for synonym sets, good and great now share some dimensions and finally look more similar than good and bad. But look at the output above: WordNet says sorry is a synonym of bad, which is true only in phrases like “I feel bad about it.” Resources like this miss nuance, reflect their builders' judgments, never keep up with new words (rizz, ghosting, LLM), and take years of expert work to build, which is why most of the world's languages don't have one. We'd rather have a method that learns word meaning from text itself.

2You shall know a word by the company it keeps

Try a small experiment. Suppose you've never seen the word ongchoi, and you come across these sentences:

Ongchoi is delicious sautéed with garlic.
Ongchoi is superb over rice.
… ongchoi leaves with salty sauces …
… spinach sautéed with garlic over rice …
… chard stems and leaves are delicious …
… collard greens and other salty leafy greens …
It's a leafy green vegetable like spinach. (Specifically, water spinach, 空心菜.) You worked that out without a definition, using only the words that appear around it.
Figure 2. Guessing a word's meaning from its contexts. Example from Jurafsky & Martin, Speech and Language Processing, Ch. 6.

This is the core idea behind almost all of modern NLP, and it has a name: the distributional hypothesis. Words that appear in similar contexts tend to have similar meanings. The linguist J.R. Firth put it memorably:

You shall know a word by the company it keeps.J.R. Firth, 1957

Here is the same idea as a table. Each column is a sentence with a blank, and each row records whether a word fits the blank:

C1C2C3C4
wine1111
juice1101
loud0000
apples0101
choices0100
motor-oil1001
C1: A bottle of ___ is on the table.
C2: Everybody likes ___.
C3: Don't have ___ before you drive.
C4: I bought ___ yesterday.
Figure 3. wine and juice fit nearly the same contexts, so their rows are nearly identical. The row is a crude representation of the word's meaning.

That last sentence is the plan for the rest of this reading: represent each word by a row of context counts, then clean those counts up until similar rows really do mean similar words.

3Counting contexts

What exactly counts as a “context”? There are two classic answers.

In a term–document matrix, the context is the whole document. Each row is a word, each column is a document, and each cell counts how often the word appears in that document. This is the matrix behind classic information retrieval. In the matrix below, fool and wit have similar rows because both show up mostly in Shakespeare's comedies.

As You Like ItTwelfth NightJulius CaesarHenry V
battle10713
good114806289
fool365814
wit201523
Figure 4. A term–document matrix for four Shakespeare plays (counts from SLP3, Ch. 6).

A document is a pretty big context, though. For word meaning, we usually want something more local. In a word–word co-occurrence matrix, the context of a word is the set of words within a window of $\pm k$ positions around it. We slide through the corpus, and every time target word $w$ appears within $k$ words of context word $c$, we add 1 to cell $(w, c)$. Try it below: click any word to see its window, and move the slider to change $k$.

Window size k = 2
Row for “apple” (counts summed over every occurrence)
Figure 5 (interactive). Building one row of a co-occurrence matrix. Notice that a, and, and with show up around almost every word. Keep that in mind; it becomes a problem in Section 5.

After processing a real corpus, the matrix looks something like this. Each row is now a vector for one word, with one dimension per vocabulary word:

computerdataeatresultsugar
apple00802
bread00901
digital65020
information410020
Figure 6. A small slice of a word–word matrix. In the full matrix, each row has $|\mathcal V|$ entries and almost all of them are 0: the vectors are sparse.

How big should the window be?

The window size is a real design decision, because it changes what kind of similarity the vectors capture. With a small window ($\pm1$ to $\pm3$), a word's contexts are mostly its grammatical neighbors, so words end up similar when they can substitute for each other. With a large window ($\pm5$ to $\pm10$), contexts start to include the whole topic of the passage, so words end up similar when they belong to the same topic. Levy and Goldberg (2014) show this nicely with the nearest neighbors of Hogwarts:

Small window (±2)
SunnydaleEvernightBlandingsCollinwood
other fictional schools and places
Large window (±5)
Dumbledorehalf-bloodMalfoySnape
the Harry Potter world
Figure 7. Nearest neighbors of Hogwarts with two context window sizes (Levy & Goldberg, 2014).

Neither answer is wrong. The context you choose defines the similarity you get.

4Comparing word vectors

Once each word is a vector, we need a way to measure how similar two vectors are. The raw dot product $\mathbf u\cdot\mathbf v$ is a tempting choice, but it has a bias: frequent words have more co-occurrences, so their vectors are longer and their dot products are large with everything. The standard fix is to divide by the vectors' lengths. This gives the cosine similarity, the cosine of the angle between the vectors:

$$\cos(\mathbf u,\mathbf v)=\frac{\mathbf u\cdot\mathbf v}{\|\mathbf u\|\,\|\mathbf v\|}=\frac{\sum_i u_i v_i}{\sqrt{\sum_i u_i^2}\,\sqrt{\sum_i v_i^2}}$$

Cosine ranges from $-1$ to $1$, and since counts are never negative, it stays between 0 and 1 for count vectors. Let's compute one by hand using the rows from Figure 6. With $\text{apple}=[0,0,8,0,2]$ and $\text{bread}=[0,0,9,0,1]$:

$$\cos(\text{apple},\text{bread})=\frac{8\cdot9+2\cdot1}{\sqrt{8^2+2^2}\,\sqrt{9^2+1^2}}=\frac{74}{\sqrt{68}\sqrt{82}}\approx 0.991$$
Figure 8. Cosine similarity between all pairs of words in Figure 6. Food words are similar to each other, computing words are similar to each other, and the two groups share nothing.

Try it yourself below. Edit either vector or pick a preset, and follow each step of the formula. The slider multiplies the second vector by a constant. Watch the dot product grow while the cosine stays exactly the same: cosine only cares about direction, not length.

Multiply the second vector by 1×
Figure 9 (interactive). Cosine similarity step by step. Right: the two vectors drawn in the plane they span, at their true angle and relative lengths. Scaling a vector changes its length but not the angle.

So far, so good. Unfortunately, this tidy picture depends on the fact that we hid some columns.

5From raw counts to PPMI

The problem: frequent words dominate

Real text is full of function words like the, it, of, and and, which appear next to nearly everything. Add just two of them back to the matrix:

computerdataeatresultsugartheit
apple0080210467
bread009019576
digital6502010165
Figure 10. Now $\cos(\text{apple},\text{bread})\approx$ , but $\cos(\text{apple},\text{digital})\approx$ , which is even higher.

The cosine is now dominated by the two huge columns, and apple looks more similar to digital than to bread. The issue is that raw counts measure how frequent a context word is, not how informative it is. Seeing apple next to the tells us nothing; seeing it next to eat tells us a lot. We need a weighting that asks a sharper question: is this co-occurrence more common than we'd expect by chance?

Pointwise mutual information

That question is exactly what pointwise mutual information (PMI) measures. For two events $x$ and $y$:

$$\text{PMI}(x,y)=\log_2\frac{P(x,y)}{P(x)\,P(y)}$$

The numerator is how often $x$ and $y$ actually occur together. The denominator is how often they would occur together if they were independent. If the two are equal, the ratio is 1 and PMI is 0. PMI is positive when the pair co-occurs more often than chance, and negative when it co-occurs less often.

Here's a concrete example. Suppose our corpus has $N=10^6$ (word, context) pairs, where apple appears 1,000 times, eat 5,000 times, and the 60,000 times. apple co-occurs with eat 100 times and with the 60 times. Then

$$\text{PMI}(\text{apple},\text{eat})=\log_2\frac{100/10^6}{(1000/10^6)(5000/10^6)}=\log_2 20\approx 4.32$$ $$\text{PMI}(\text{apple},\text{the})=\log_2\frac{60/10^6}{(1000/10^6)(60000/10^6)}=\log_2 1=0$$

Raw counts would treat 60 and 100 as roughly comparable. PMI doesn't: 60 is exactly what chance predicts for a word as common as the, so that pair gets zero weight, while apple appears with eat 20 times more often than chance, a strong signal.

PMI always compares two numbers: how often a pair was observed together, and how often it would be expected together by chance, $\text{count}(x)\cdot\text{count}(y)/N$. Plug in your own counts, or start from one of the presets:

Figure 11 (interactive). A PMI calculator. Observed vs. expected co-occurrence counts, and the PMI they produce.

Positive PMI

In practice we use positive PMI (PPMI), which replaces negative values with 0:

$$\text{PPMI}(w,c)=\max\!\left(\log_2\frac{P(w,c)}{P(w)\,P(c)},\;0\right)$$

Why throw away negative values? A negative PMI claims that two words co-occur less than chance. For two words that each have probability around $10^{-6}$, you'd need far more than $10^{12}$ word pairs to reliably tell “less than chance” apart from “just didn't happen to see it.” Negative estimates are mostly noise. A nice side effect is that zeros stay zero, so the matrix stays sparse.

All of the probabilities come straight from the count matrix. If $f_{ij}$ is the count in row $i$ and column $j$, and $N=\sum_{i,j}f_{ij}$ is the total, then

$$P(w_i,c_j)=\frac{f_{ij}}{N},\qquad P(w_i)=\frac{\sum_j f_{ij}}{N},\qquad P(c_j)=\frac{\sum_i f_{ij}}{N}$$

In words, each PPMI entry needs only the cell, its row total, its column total, and the grand total. Use the buttons below to switch between raw counts and PPMI on a slightly larger toy matrix and watch what happens to the similarities:

Figure 12 (interactive). Left: the weighted matrix (toy counts for illustration). Right: cosine similarity between every pair of word vectors.

A weak spot: rare words

PMI has one well-known flaw: it overrates rare events. Suppose a word and a context each appear exactly once in the corpus, and that one time they appear together. Then

$$\text{PMI}=\log_2\frac{1/N}{(1/N)(1/N)}=\log_2 N$$

With $N=10^6$, that's about 19.9, much larger than the 4.32 we computed for apple/eat, based on a single observation. A typo or an obscure name can end up with the strongest association in the whole matrix. Common fixes (Levy, Goldberg & Dagan, 2015) include:

To see context smoothing at work, take one row of a toy matrix with $N=85{,}905$ (word, context) pairs. The word pie appears 1,000 times. Its contexts range from the very common the to quince, which appears only 5 times in the whole corpus and only once next to pie. Slide $\alpha$ from 1 (no smoothing) down to 0.5:

α = 1.00

Figure 13 (interactive). Context distribution smoothing. Raising counts to the power $\alpha$ moves probability from frequent contexts to rare ones. That shrinks the PMI of rare contexts, which were the ones being overrated.
Aside: TF-IDF. For term–document matrices, the traditional weighting is TF-IDF, which follows the same spirit. Term frequency is squashed with a log, $\text{tf}_{t,d}=\log_{10}(\text{count}(t,d)+1)$, and multiplied by inverse document frequency, $\text{idf}_t=\log_{10}(N_\text{docs}/\text{df}_t)$. A word that appears in every document, like good in all 37 Shakespeare plays, gets $\text{idf}=0$, while Romeo, which appears in only one play, gets $\log_{10}37\approx1.57$. Like PPMI, TF-IDF down-weights what's everywhere and up-weights what's distinctive.

6From sparse to dense: SVD

PPMI vectors are a big improvement, but they are still long (one dimension per vocabulary word, often tens of thousands) and sparse (mostly zeros). We would much prefer short, dense vectors of maybe 50 to 300 real numbers. Short vectors are cheaper to use as features in a model. Dense vectors also tend to generalize better, and in practice they simply work better.

There's also a deeper reason. Suppose word $w_1$ often appears near car, and word $w_2$ often appears near automobile. Intuitively $w_1$ and $w_2$ should be similar. But in a sparse vector, car and automobile are two separate dimensions, so they contribute nothing to $\cos(w_1,w_2)$. Sparse vectors can't capture this kind of higher-order co-occurrence. What we want is a representation that notices car and automobile behave alike and merges them into a single “vehicle” direction.

That is what the singular value decomposition (SVD) does. Any matrix $X$ can be factored as $X=U\Sigma V^\top$, where $\Sigma$ is a diagonal matrix of non-negative singular values sorted from largest to smallest. The largest singular values correspond to the most important patterns in the data. If we keep only the top $k$ and discard the rest, we get the best possible rank-$k$ approximation of $X$:

X |V| × |C| ≈ U dropped |V| × k · Σ k × k · Vᵀ k × |C|
Figure 14. Truncated SVD. Only the shaded parts are kept. Each word's new vector is its row of $W=U_k\Sigma_k$ (some implementations use just $U_k$).

Each row of $U_k\Sigma_k$ is a dense $k$-dimensional vector for one word. Because SVD has to explain as much of the matrix as possible with only $k$ directions, contexts that behave similarly (car and automobile) get folded into the same directions. When this technique is applied to a term–document matrix, it's known as Latent Semantic Analysis (LSA) (Deerwester et al., 1990), one of the earliest successful word embedding methods.

The figure below compresses each 7-dimensional row of our toy matrix into just 2 numbers so we can plot it. Compare SVD on raw counts with SVD on PPMI:

Figure 15 (interactive). Words plotted using the top two SVD dimensions ($k=2$).

How many dimensions should we keep? The figure below rebuilds the PPMI matrix from only its top $k$ singular values, $X_k=U_k\Sigma_kV_k^\top$. With small $k$, the vectors are short but lose detail. As $k$ grows, $X_k$ gets closer to $X$, but we compress less. Real systems use $k$ between about 50 and 300 for vocabularies of many thousands of words.

Keep k = 2
Singular values

PPMI matrix X
Rank-2 reconstruction Xk
Figure 16 (interactive). Truncated SVD of the PPMI matrix from Figure 12. The shaded bars are the singular values we keep. Both matrices use the same color scale; blue cells are small negative values that SVD can introduce.

7Putting it all together

The complete recipe has four steps: count co-occurrences in a $\pm k$ window, weight them with PPMI, reduce the dimension with truncated SVD, and use the resulting rows as word vectors. The whole thing fits in about twenty lines of NumPy:

import numpy as np

def cooccurrence(corpus, vocab, window=2):
    idx = {w: i for i, w in enumerate(vocab)}
    M = np.zeros((len(vocab), len(vocab)))
    for sent in corpus:                                # corpus: list of token lists
        for i, w in enumerate(sent):
            lo, hi = max(0, i - window), min(len(sent), i + window + 1)
            for j in range(lo, hi):
                if j != i and w in idx and sent[j] in idx:
                    M[idx[w], idx[sent[j]]] += 1
    return M

def ppmi(M):
    N = M.sum()
    expected = np.outer(M.sum(1), M.sum(0)) / N             # = N * P(w) * P(c)
    with np.errstate(divide="ignore", invalid="ignore"):
        pmi = np.log2(M / expected)                          # = log2 P(w,c) / (P(w) P(c))
    return np.nan_to_num(np.maximum(pmi, 0))             # -inf and nan become 0

X = ppmi(cooccurrence(corpus, vocab, window=4))
U, S, Vt = np.linalg.svd(X)
W = U[:, :100] * S[:100]                                   # |V| x 100 word vectors
Figure 17. Count → PPMI → SVD. For a real vocabulary (say 100k words), a dense matrix would need about 80 GB, so in practice you'd use scipy.sparse and a truncated solver such as sklearn.decomposition.TruncatedSVD.

This approach has real strengths. It's simple and transparent, involves no training loop, and makes direct use of statistics gathered from the whole corpus. It also has clear limitations. SVD becomes expensive for very large vocabularies, and adding a new word means recomputing everything. There are many hand-tuned choices (window size, weighting, $\alpha$, $k$). And every word gets exactly one vector, so bank as in “river bank” and bank as in “savings bank” share a single representation.

8Check your understanding

Exercise: PPMI by hand

Compute the PPMI matrix (using $\log_2$) for the following counts. The total is $N=10$.

eatdriverow total
apple314
car156
column total4610
apple, eat: $\log_2\frac{0.3}{0.4\times0.4}=\log_2 1.875\approx 0.91$
apple, drive: $\log_2\frac{0.1}{0.4\times0.6}\approx-1.26\;\to\;0$
car, eat: $\log_2\frac{0.1}{0.6\times0.4}\approx-1.26\;\to\;0$
car, drive: $\log_2\frac{0.5}{0.6\times0.6}=\log_2 1.389\approx 0.47$
The one-off co-occurrences (apple/drive, car/eat) are wiped out, and only the meaningful associations remain.

Quick quiz

9Further reading