Singular Value Decomposition › Code Implementation

Michael BrenndoerferUpdated March 21, 202612 min read
Language AI HandbookAll chapters
Part 3 of 67Distributional Semantics
Chapter 18Singular Value Decomposition › Code Implementation
Reading section 10 of 14 · Code Implementation · 12 minSection 10 of 14 · 12 min

Book contents

Section 10 of 14 · 12 min read

Singular Value Decomposition › Code ImplementationOne section · Sections in this chapter10 / 14 ↓
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 us implement LSA from scratch to see each step clearly, then compare with scikit-learn's TruncatedSVD for practical use.

First, we install and import all required libraries:

Building a PPMI Matrix

We start with a small corpus to build a word-context co-occurrence matrix, then apply PPMI weighting as covered in the previous chapter. The corpus spans two distinct domains (animals and programming) and includes "bank" as a deliberately ambiguous word that bridges finance and geography:

In[5]:
Code
# Small corpus covering two domains: animals and programming
corpus = [
    "cat pet animal feed",
    "dog pet animal feed",
    "kitten cat animal",
    "puppy dog animal",
    "python code software program",
    "java code software program",
    "compiler python software",
    "algorithm java code",
    "bank finance money",
    "bank river water",
]

# Build vocabulary
words = sorted(set(w for doc in corpus for w in doc.split()))
word_to_idx = {w: i for i, w in enumerate(words)}
vocab_size = len(words)
Out[6]:
Console
Vocabulary size: 19
Words: ['algorithm', 'animal', 'bank', 'cat', 'code', 'compiler', 'dog', 'feed', 'finance', 'java', 'kitten', 'money', 'pet', 'program', 'puppy', 'python', 'river', 'software', 'water']
In[7]:
Code
# Build co-occurrence matrix (document-level context)
# Each document is treated as a context window: all words in the same
# document are considered context for each other.
cooc = np.zeros((vocab_size, vocab_size))
for doc in corpus:
    doc_words = doc.split()
    for i, w1 in enumerate(doc_words):
        for w2 in doc_words:
            if w1 != w2:
                cooc[word_to_idx[w1], word_to_idx[w2]] += 1

# Apply PPMI weighting
total = cooc.sum()
row_sums = cooc.sum(axis=1, keepdims=True)
col_sums = cooc.sum(axis=0, keepdims=True)

# PMI = log2(P(w,c) / (P(w) * P(c)))
# Small epsilon avoids log(0) for zero co-occurrences
epsilon = 1e-12
pmi = np.log2(
    (cooc / total + epsilon)
    / ((row_sums / total) * (col_sums / total) + epsilon)
)

# PPMI: keep only positive PMI values
ppmi = np.maximum(pmi, 0)
Out[8]:
Console
PPMI matrix shape: (19, 19)
Sparsity (fraction zero): 82.27%

Most entries are zero. This reflects the sparsity typical of real co-occurrence matrices. PPMI has converted raw counts into meaningful association scores, and now we apply SVD to compress these into dense embeddings.

Computing SVD

We use numpy's linalg.svd to compute the full decomposition, then examine the singular value spectrum. The full_matrices=False option returns the economy (thin) SVD, which computes only r=min⁡(m,n)r = \min(m, n) components rather than the full square matrices:

In[9]:
Code
# Full (thin) SVD
U, s, Vt = np.linalg.svd(ppmi, full_matrices=False)

# Fraction of variance explained by each component
# Each singular value squared is proportional to the variance it explains
variance_explained = s**2 / (s**2).sum()
cumulative_variance = variance_explained.cumsum()
Out[10]:
Console
Top singular values and their contribution to total variance:
Component         Sigma   Variance   Cumulative
------------------------------------------------
  1               9.327      22.8%        22.8%
  2               7.406      14.4%        37.3%
  3               7.369      14.3%        51.5%
  4               4.935       6.4%        57.9%
  5               4.392       5.1%        63.0%
  6               4.392       5.1%        68.0%
  7               4.392       5.1%        73.1%
  8               4.137       4.5%        77.6%

The first few components capture most of the variance. The rapid drop-off in singular values tells us the PPMI matrix has measurable low-rank structure: only a few latent dimensions are needed to capture the essential patterns, and the remainder is noise.

Truncated Embeddings

We form the word embeddings by projecting onto the top kk singular vectors, scaling each coordinate by the corresponding singular value. The scaling is important: it ensures that the distance between embeddings in the truncated space reflects the contribution of each dimension to the original matrix:

In[11]:
Code
# Truncated SVD: k=2 for visualization, k=4 for richer embeddings
k = 2
U_k = U[:, :k]
s_k = s[:k]

# Word embeddings: rows of U_k * Sigma_k
# Shape: (vocab_size, k)
# Each row is the k-dimensional embedding for one word.
word_embeddings_2d = U_k * s_k

# Also compute k=4 embeddings for similarity comparison
k4 = min(4, len(s))
word_embeddings_4d = U[:, :k4] * s[:k4]
Out[12]:
Console
2D word embeddings (U_k * Sigma_k):
Word                Dim 1      Dim 2
------------------------------------
  algorithm        +0.000     -0.000
  animal           +0.000     -3.693
  bank             -5.487     -0.000
  cat              +0.000     -2.882
  code             +0.000     -0.000
  compiler         +0.000     -0.000
  dog              +0.000     -2.882
  feed             +0.000     -2.710
  finance          -3.771     -0.000
  java             +0.000     -0.000
  kitten           +0.000     -2.227
  money            -3.771     -0.000
  pet              +0.000     -2.710
  program          +0.000     -0.000
  puppy            +0.000     -2.227
  python           +0.000     -0.000
  river            -3.771     -0.000
  software         +0.000     -0.000
  water            -3.771     -0.000

The embedding coordinates directly reflect the semantic structure. Words from the same domain cluster together in the 2D space, and the ambiguous word "bank" should occupy an intermediate position. This reflects its membership in both the finance and nature clusters.

Visualizing the Embedding Space

A 2D scatter plot lets us see the semantic geometry directly:

Out[13]:
Visualization
Scatter plot of 2D LSA word embeddings with animal words clustered separately from programming words.
Two-dimensional LSA word embeddings from truncated SVD (k=2) applied to a PPMI co-occurrence matrix. Animal-related words (cat, dog, kitten, puppy) cluster together at upper left, programming-related words (python, java, compiler, algorithm) form a separate group at upper right, and nature or finance words (bank, river, water, money) occupy the lower region. The spatial separation confirms that SVD has recovered the underlying domain structure from distributional patterns alone.

Singular Value Spectrum

Visualizing the singular value decay curve helps guide the choice of kk and reveals how much low-rank structure the corpus contains:

Out[14]:
Visualization
Bar chart of singular values showing rapid decay from large first values to near zero.
Singular value magnitudes for the PPMI matrix in decreasing order. The rapid decline from the first few large values to a flat tail is characteristic of NLP co-occurrence matrices and signals strong low-rank structure. Components beyond the elbow add progressively less signal.
Line chart of cumulative variance explained rising steeply then flattening, with 80% and 95% reference lines.
Cumulative variance explained as components are added sequentially. The 80% and 95% thresholds (dashed lines) show that a small number of SVD components accounts for most of the meaningful variation in the PPMI matrix, justifying aggressive truncation.

The left panel shows the characteristic "elbow" shape: a few large singular values followed by a rapid drop to a flat tail. The right panel shows that just a few components explain the majority of variance in this small matrix. In a real corpus with hundreds of thousands of words, the number of components needed to reach 80% explained variance would be much larger, but the qualitative shape of both curves would be similar.

Cosine Similarity in Embedding Space

One of the most important tests for embedding quality is whether semantically similar words have high cosine similarity. Cosine similarity measures the angle between two vectors, ranging from -1 (opposite directions) to +1 (same direction), with 0 indicating orthogonality. Unlike Euclidean distance, cosine similarity is invariant to vector magnitude, which makes it reliable when comparing words that vary in frequency:

In[15]:
Code
def cosine_similarity(v1, v2):
    """Compute cosine similarity between two vectors."""
    norm1 = np.linalg.norm(v1)
    norm2 = np.linalg.norm(v2)
    if norm1 == 0 or norm2 == 0:
        return 0.0
    return np.dot(v1, v2) / (norm1 * norm2)


# Compare pairs using 4D embeddings for richer signal
word_pairs = [
    ("cat", "dog"),  # Should be similar (both animals)
    ("python", "java"),  # Should be similar (both programming)
    ("cat", "python"),  # Should differ (different domains)
    ("bank", "finance"),  # Should be moderately similar (financial context)
    ("bank", "river"),  # Should be moderately similar (water context)
]

similarities = {}
for w1, w2 in word_pairs:
    if w1 in word_to_idx and w2 in word_to_idx:
        v1 = word_embeddings_4d[word_to_idx[w1]]
        v2 = word_embeddings_4d[word_to_idx[w2]]
        similarities[(w1, w2)] = cosine_similarity(v1, v2)
Out[16]:
Console
Cosine similarities (k=4 LSA embeddings):
Word Pair                 Similarity
-------------------------------------
cat vs dog                   +1.0000
python vs java               +1.0000
cat vs python                -0.0000
bank vs finance              +0.5434
bank vs river                +0.5434

The similarity scores validate that LSA has captured domain structure. Within-domain pairs ("cat vs dog" and "python vs java") score much higher than cross-domain pairs ("cat vs python"). The ambiguous word "bank" shows moderate similarity to both finance-related and nature-related words, correctly reflecting its distributional ambiguity. This is LSA working as intended: the compressed representations encode the distributional patterns of the training corpus.

Using scikit-learn's TruncatedSVD

For real-world use with large sparse matrices, scikit-learn's TruncatedSVD is far more efficient than full SVD. It uses randomized SVD internally, handles sparse inputs natively, and provides variance explained metrics directly:

In[17]:
Code
from scipy.sparse import csr_matrix
from sklearn.decomposition import TruncatedSVD

# Convert PPMI to sparse format (essential for large vocabularies)
ppmi_sparse = csr_matrix(ppmi)

# TruncatedSVD uses randomized SVD internally for efficiency
k = 4
svd = TruncatedSVD(n_components=k, random_state=42)
embeddings_sklearn = svd.fit_transform(ppmi_sparse)
Out[18]:
Console
Input matrix shape: (19, 19)
Embedding matrix shape: (19, 4)
Total variance explained: 49.8%

Per-component variance:
  Component 1: 20.3%
  Component 2: 11.0%
  Component 3: 10.9%
  Component 4: 7.6%

Note that TruncatedSVD.fit_transform returns the matrix UkΣk\mathbf{U}_k \mathbf{\Sigma}_k directly: the row embeddings already incorporate the singular value scaling. The output shape of (vocab_size, k) gives one kk-dimensional embedding per word, ready for downstream use.

Comparing k Values

How does the choice of kk affect embedding quality? We can compare similarity rankings across different truncation levels to understand the tradeoff between noise reduction and semantic resolution:

In[19]:
Code
k_values = [1, 2, 4, 6]
similarity_by_k = {}

for k_val in k_values:
    k_actual = min(k_val, len(s))
    emb = U[:, :k_actual] * s[:k_actual]
    sim_dict = {}
    for w1, w2 in word_pairs:
        if w1 in word_to_idx and w2 in word_to_idx:
            v1 = emb[word_to_idx[w1]]
            v2 = emb[word_to_idx[w2]]
            sim_dict[(w1, w2)] = cosine_similarity(v1, v2)
    similarity_by_k[k_val] = sim_dict
Out[20]:
Console
Cosine similarity across k values:
Word Pair                  k= 1  k= 2  k= 4  k= 6
---------------------------------------------------------
cat vs dog                 +1.00  +1.00  +1.00  +1.00
python vs java             +1.00  +0.96  +1.00  +1.00
cat vs python              +1.00  +0.11  -0.00  -0.00
bank vs finance            +1.00  +1.00  +0.54  +0.41
bank vs river              +1.00  +1.00  +0.54  +0.40

This comparison shows how the similarity structure evolves as we include more dimensions. With k=1k=1, only the dominant pattern is captured, and the full semantic picture is compressed to a single axis. With k=4k=4, finer distinctions emerge, and word pairs that share secondary contexts begin to show differentiated similarities.

A heatmap makes this evolution easy to see across all word pairs and all kk values simultaneously:

Out[21]:
Visualization
Heatmap with word pairs as rows and k values as columns, showing cosine similarity values by color intensity.
Cosine similarities between word pairs at different SVD truncation levels (k=1 to k=6). Within-domain pairs (cat vs dog, python vs java) maintain high similarity across all k values. Cross-domain pairs (cat vs python) remain near zero throughout. The ambiguous word bank shows increasing differentiation as k grows, with its similarity to finance and river diverging as more latent dimensions are captured, which reflects the richer contextual signal available at higher dimensionality.

Key Parameters

The key parameters for TruncatedSVD are:

  • n_components: The number of singular vectors to compute, controlling the embedding dimensionality. This is the most important parameter, and the choice follows the guidance in the section on choosing kk above.
  • algorithm: Either 'randomized' (default, fast for large matrices) or 'arpack' (exact, better for small matrices or when high precision is needed). For most NLP applications with matrices larger than a few thousand words, 'randomized' is strongly preferred.
  • n_iter: Number of power iteration steps for the randomized algorithm. More iterations improve approximation quality at the cost of computation time. The default of 4 is sufficient for most NLP matrices.
  • random_state: Random seed for reproducibility when using the randomized algorithm. Always set this to ensure consistent results across runs.