Co-occurrence Matrices › Code Implementation

Michael BrenndoerferUpdated January 25, 202615 min read
Language AI HandbookAll chapters
Part 3 of 67Distributional Semantics
Chapter 16Co-occurrence Matrices › Code Implementation
Reading section 9 of 13 · Code Implementation · 15 minSection 9 of 13 · 15 min

Book contents

Section 9 of 13 · 15 min read

Co-occurrence Matrices › Code ImplementationOne section · Sections in this chapter9 / 13 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement co-occurrence matrix construction from scratch, then use it to demonstrate the key concepts.

We will start by building a simple corpus and vocabulary pipeline.

In[4]:
Code
# Sample corpus
corpus = [
    "the cat sat on the mat",
    "the cat ate the rat",
    "the dog sat on the log",
    "the dog ran in the park",
    "the cat chased the rat in the park",
    "dogs and cats are common pets",
    "a cat or a dog makes a good pet",
    "the rat ran from the cat",
    "cats and dogs play in the garden",
    "the mat sat under the cat",
]

# Tokenize and build vocabulary
all_tokens = []
sentences = []
for sent in corpus:
    tokens = sent.lower().split()
    sentences.append(tokens)
    all_tokens.extend(tokens)

# Count frequencies and build vocabulary (keep words with freq >= 2)
from collections import Counter

freq = Counter(all_tokens)
vocab = sorted(w for w, c in freq.items() if c >= 2)
word2idx = {w: i for i, w in enumerate(vocab)}
idx2word = {i: w for w, i in word2idx.items()}
V = len(vocab)
Out[5]:
Console
Corpus size:    10 sentences, 65 tokens
Vocabulary size: 14 words (after frequency cutoff of 2)

Vocabulary: ['a', 'and', 'cat', 'cats', 'dog', 'dogs', 'in', 'mat', 'on', 'park', 'ran', 'rat', 'sat', 'the']

Our corpus has a small vocabulary after applying the minimum frequency cutoff. In practice, corpora have vocabularies of 50,000 to 500,000 words, but the construction logic is identical.

Now let's build the word-word co-occurrence matrix with uniform weights, then with distance weighting, to compare them.

In[6]:
Code
def build_cooccurrence_matrix(
    sentences, word2idx, window_size=2, weighted=False
):
    """Build a word-word co-occurrence matrix.

    Args:
        sentences: List of tokenized sentences
        word2idx: Mapping from word to index
        window_size: Context window half-size
        weighted: If True, weight co-occurrences by 1/distance

    Returns:
        Dense numpy array of shape (V, V)
    """
    V = len(word2idx)
    C = np.zeros((V, V), dtype=float)

    for sent in sentences:
        # Map tokens to indices, skipping OOV words
        indices = [word2idx[w] for w in sent if w in word2idx]

        for i, target_idx in enumerate(indices):
            # Context window bounds
            left = max(0, i - window_size)
            right = min(len(indices), i + window_size + 1)

            for j in range(left, right):
                if i == j:
                    continue
                context_idx = indices[j]
                distance = abs(i - j)
                weight = 1.0 / distance if weighted else 1.0
                C[target_idx, context_idx] += weight

    return C


# Build both versions
C_uniform = build_cooccurrence_matrix(
    sentences, word2idx, window_size=2, weighted=False
)
C_weighted = build_cooccurrence_matrix(
    sentences, word2idx, window_size=2, weighted=True
)
Out[7]:
Console
Co-occurrence matrix (uniform weights) for 'cat':
  the            : 7.0
  a              : 2.0
  rat            : 2.0
  sat            : 2.0
  dog            : 1.0
  on             : 1.0
  ran            : 1.0
Out[8]:
Console
Co-occurrence matrix (distance-weighted) for 'cat':
  the            : 7.00
  a              : 2.00
  sat            : 1.50
  rat            : 1.00
  dog            : 0.50
  on             : 0.50
  ran            : 0.50

Notice that distance weighting shifts the emphasis away from "the" (which appears near everything but at variable distances) and toward words that consistently appear immediately adjacent to "cat." The function word "the" is still present, but its relative weight decreases because many of its co-occurrences with "cat" happen at distance 2 rather than distance 1. Words that co-occur reliably at distance 1 get full weight, and their signal is more prominent in the weighted matrix. This tends to give better semantic signal in downstream similarity computations.

Now let's examine the sparsity of the matrix.

In[9]:
Code
total_entries = V * V
nonzero_count = np.count_nonzero(C_uniform)
sparsity = 1.0 - (nonzero_count / total_entries)
density = nonzero_count / total_entries
Out[10]:
Console
Matrix dimensions: 14 x 14 = 196 total entries
Nonzero entries:   68
Density:           34.7%
Sparsity:          65.3%

Memory (dense, float64): 1.5 KB
Memory (sparse COO):     1.6 KB  (value + row + col)

Even in this tiny example, the matrix is sparse. For a real vocabulary of 50,000 words, over 99.9% of entries would be zero.

Now let's build the word-document matrix and compare what it captures versus the word-word matrix.

In[11]:
Code
def build_word_document_matrix(corpus, word2idx):
    """Build a word-document co-occurrence matrix.

    Returns:
        Dense array of shape (V, D)
    """
    V = len(word2idx)
    D = len(corpus)
    M = np.zeros((V, D), dtype=float)

    for doc_idx, sent in enumerate(corpus):
        tokens = sent.lower().split()
        for word in tokens:
            if word in word2idx:
                M[word2idx[word], doc_idx] += 1

    return M


M_doc = build_word_document_matrix(corpus, word2idx)
Out[12]:
Console
Word-document matrix shape: (14, 10)
(V=14 words x D=10 documents)

'cat' appears in documents: [0, 1, 4, 6, 7, 9]
'dog' appears in documents: [2, 3, 6]
Shared documents (cat & dog): [np.int64(6)]
In[13]:
Code
# Compare word similarity from word-word vs word-document matrices
from numpy.linalg import norm


def cosine_similarity(u, v):
    """Compute cosine similarity between two vectors."""
    u_norm = norm(u)
    v_norm = norm(v)
    if u_norm == 0 or v_norm == 0:
        return 0.0
    return np.dot(u, v) / (u_norm * v_norm)


# Find most similar words to 'cat' in each matrix
target = "cat"
target_idx = word2idx[target]

# Word-word similarities
ww_sims = {}
for w, idx in word2idx.items():
    if w != target:
        ww_sims[w] = cosine_similarity(C_uniform[target_idx], C_uniform[idx])

# Word-document similarities
wd_sims = {}
for w, idx in word2idx.items():
    if w != target:
        wd_sims[w] = cosine_similarity(M_doc[target_idx], M_doc[idx])
Out[14]:
Console
Top-5 words similar to 'cat' (word-word matrix):
  mat         : 0.905
  ran         : 0.866
  sat         : 0.829
  rat         : 0.808
  in          : 0.800

Top-5 words similar to 'cat' (word-document matrix):
  the         : 0.770
  rat         : 0.707
  mat         : 0.577
  sat         : 0.471
  a           : 0.408

The word-word matrix tends to surface words that appear in the same immediate grammatical contexts (words that co-occur locally with "cat"), while the word-document matrix surfaces words that appear in the same documents (thematically related words). Both are valid and useful depending on the downstream task.

Visualizing the Co-occurrence Structure

Let's visualize the co-occurrence matrix as a heatmap and the effect of window size on the captured relationships.

In[15]:
Code
# Build matrices with different window sizes to compare
C_window1 = build_cooccurrence_matrix(
    sentences, word2idx, window_size=1, weighted=False
)
C_window4 = build_cooccurrence_matrix(
    sentences, word2idx, window_size=4, weighted=False
)
Out[16]:
Visualization
Heatmap of word-word co-occurrence matrix with window size 1, showing sparse local patterns.
Co-occurrence matrix with window size 1, capturing tight local context. Darker cells indicate more frequent co-occurrences. High counts appear for grammatically adjacent pairs like articles with nouns. The pattern is sparse, which reflects the narrow scope of the window.
Heatmap of word-word co-occurrence matrix with window size 4, showing denser, more uniform patterns.
Co-occurrence matrix with window size 4, capturing broader semantic context. The matrix fills in more extensively as more word pairs fall within the wider window. Function words now accumulate high counts with nearly all content words, visible as uniformly dark rows and columns for high-frequency words.

The heatmaps reveal the qualitative difference between small and large windows. With window size 1, only words that are grammatically adjacent accumulate high counts, and most cells remain at or near zero. The matrix is visually sparse. With window size 4, the matrix fills in substantially, and the rows corresponding to high-frequency function words ("the," "a," "in") become uniformly bright because these words co-occur with nearly every other word within a wide window. The content words still show more differentiated patterns, but they are increasingly dominated by function word noise.

Out[17]:
Visualization
Bar chart comparing uniform and distance-weighted co-occurrence counts for the word cat.
Comparison of uniform versus distance-weighted co-occurrence counts for the word 'cat'. Distance weighting reduces the dominance of high-frequency function words that appear far from the target, amplifying the signal from nearby, more semantically relevant context words. The relative counts shift toward words that reliably appear immediately adjacent to 'cat'.
Out[18]:
Visualization
Horizontal bar chart of word similarity to cat using word-word matrix.
Cosine similarity of all vocabulary words to 'cat' using the word-word co-occurrence matrix (window = 2). Words appearing in similar local contexts rank highest, which reflects shared grammatical roles and common narrative situations.
Horizontal bar chart of word similarity to cat using word-document matrix.
Cosine similarity of all vocabulary words to 'cat' using the word-document matrix. Words sharing documents with 'cat' rank highest, which reflects broader topical relatedness rather than local co-occurrence patterns.

The comparison highlights the complementary strengths of each matrix type. In the word-word matrix, words that function similarly in sentences tend to cluster near each other, even if they appear in different documents. In the word-document matrix, words that populate similar topics and genres have high similarity, even if they rarely appear in the same sentence. For semantic search and information retrieval tasks, the word-document matrix is usually more appropriate. For semantic similarity at the word level, the word-word matrix is typically preferred.

Window Size Effects on Similarity

Let's quantify how window size changes the semantic neighborhood of words.

In[19]:
Code
# Build matrices at several window sizes
window_sizes = [1, 2, 3, 4, 5]
target_word = "dog"
target_idx = word2idx[target_word]
compare_word = "cat"
compare_idx = word2idx[compare_word]

# Track similarity between 'dog' and 'cat' across window sizes
dog_cat_sims = []
for ws in window_sizes:
    C = build_cooccurrence_matrix(
        sentences, word2idx, window_size=ws, weighted=False
    )
    sim = cosine_similarity(C[target_idx], C[compare_idx])
    dog_cat_sims.append(sim)
Out[20]:
Visualization
Line plot showing cosine similarity between dog and cat at window sizes 1 through 5.
Cosine similarity between 'dog' and 'cat' as a function of context window size. Similarity generally increases with window size as broader context captures more of the shared topical environment of both animals, while narrow windows capture more idiosyncratic syntactic differences. This pattern illustrates the fundamental tradeoff between syntactic precision (small windows) and semantic coverage (large windows).

The window size effect is clear: as the window grows, "dog" and "cat" accumulate more shared context words ("the," "park," "ran," "in"), which pushes their cosine similarity higher. This confirms the theoretical prediction: larger windows capture more of the topical similarity between semantically related words. A small window captures only the tightest syntactic patterns, where "dog" and "cat" might differ (because they appear in slightly different sentence frames), while a large window captures the broader distributional environment they share.

Sparse Matrix Construction

For real-scale corpora, we need sparse storage. Let's demonstrate building a sparse co-occurrence matrix using scipy's CSR format, and compare memory usage against dense storage.

In[21]:
Code
from scipy.sparse import lil_matrix


def build_sparse_cooccurrence(
    sentences, word2idx, window_size=2, weighted=False
):
    """Build co-occurrence matrix in sparse LIL format, then convert to CSR."""
    V = len(word2idx)
    # LIL (List of Lists) format is efficient for incremental construction
    C = lil_matrix((V, V), dtype=float)

    for sent in sentences:
        indices = [word2idx[w] for w in sent if w in word2idx]
        for i, target_idx in enumerate(indices):
            left = max(0, i - window_size)
            right = min(len(indices), i + window_size + 1)
            for j in range(left, right):
                if i == j:
                    continue
                context_idx = indices[j]
                distance = abs(i - j)
                weight = 1.0 / distance if weighted else 1.0
                C[target_idx, context_idx] += weight

    # Convert to CSR for efficient row-wise operations
    return C.tocsr()


C_sparse = build_sparse_cooccurrence(
    sentences, word2idx, window_size=2, weighted=True
)
Out[22]:
Console
Sparse matrix: (14, 14), 68 nonzeros
Dense storage:  1.5 KB
Sparse storage: 0.9 KB
Memory savings: 44.1%

Projected for V=50,000 vocabulary:
  Dense storage:  20.0 GB
  Sparse storage: 10408 MB  (assuming same sparsity fraction)

The memory advantage of sparse storage scales quadratically with vocabulary size. A vocabulary of 50,000 words would require 20 GB of RAM in dense format but only hundreds of megabytes in sparse format, making large-scale co-occurrence matrices practically tractable. This quadratic scaling is the reason sparse storage is non-negotiable for real applications: the savings become astronomically large as vocabulary size grows.

Visualizing the Count Distribution

One of the most instructive properties of co-occurrence matrices is the distribution of their nonzero counts. Let's visualize it to see the power law behavior in action.

In[23]:
Code
# Get all nonzero counts from the uniform matrix
nonzero_counts = C_uniform[C_uniform > 0].flatten()
nonzero_counts_sorted = np.sort(nonzero_counts)[::-1]
Out[24]:
Visualization
Histogram showing right-skewed distribution of co-occurrence counts, with most counts near 1 and a long tail of large counts.
Distribution of co-occurrence counts in the word-word matrix. The histogram shows the characteristic right-skewed shape of count distributions in language: most word pairs co-occur rarely (count = 1 or 2), while a small number of pairs, typically involving function words, accumulate very high counts. This power-law structure motivates statistical reweighting with PMI or similar measures.

The histogram confirms the heavy-tailed structure: most observed word pairs co-occur only once or twice, while a few pairs involving function words accumulate much higher counts. On a log scale, this distribution has a roughly linear shape, which is characteristic of power laws. The long tail of high counts is dominated by common grammatical patterns: "the" co-occurring with every noun, "in" co-occurring with every location noun, and so on. These patterns carry almost no discriminative semantic information, which is precisely why PMI weighting (coming in the next chapter) is essential.

Key Parameters

The key parameters for co-occurrence matrix construction are:

  • window_size: Controls the context window half-size kk. Smaller values (1-2) capture syntactic relationships; larger values (5-10) capture semantic and topical relationships.
  • weighted: Whether to apply harmonic distance weighting (1/d1/d). Distance weighting reduces function word dominance and typically improves downstream word vector quality.
  • min_frequency: Minimum word frequency for vocabulary inclusion. Rare words have unreliable statistics; values of 5-10 are common in practice.
  • symmetric: Whether to treat left and right contexts equally (symmetric) or separately (directional). Symmetric is standard for most semantic similarity tasks.
  • sentence_boundary: Whether context windows respect sentence boundaries. Enforcing sentence boundaries is linguistically motivated and standard practice.