Bias Mitigation: Debiasing, CDA, and Fair Fine-tuning › Debiasing Embeddings: A Worked Example

Michael BrenndoerferMarch 15, 20269 min read
Language AI HandbookAll chapters
Part 58 of 67Bias and Fairness
Chapter 443Bias Mitigation: Debiasing, CDA, and Fair Fine-tuning › Debiasing Embeddings: A Worked Example
Reading section 7 of 13 · Debiasing Embeddings: A Worked Example · 9 minSection 7 of 13 · 9 min

Book contents

Section 7 of 13 · 9 min read

Bias Mitigation: Debiasing, CDA, and Fair Fine-tuning › Debiasing Embeddings: A Worked ExampleOne section · Sections in this chapter7 / 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 us walk through a concrete implementation of hard debiasing for GloVe embeddings. We will load 50-dimensional GloVe vectors, identify the gender direction, and neutralize a set of occupation words.

Setup and loading. We start by loading the embeddings and defining our gendered seed pairs.

In[4]:
Code
import numpy as np

# Simulate small GloVe-like embeddings for demonstration
# In practice: load from glove.6B.50d.txt
np.random.seed(42)
vocab_size = 100
embed_dim = 50

# Create synthetic embeddings with planted gender bias
# Gender direction is approximately the first dimension
embeddings = np.random.randn(vocab_size, embed_dim) * 0.5

# Words we will work with
words = [
    "he",
    "she",
    "him",
    "her",
    "his",
    "hers",
    "man",
    "woman",
    "boy",
    "girl",
    "father",
    "mother",
    "doctor",
    "nurse",
    "engineer",
    "teacher",
    "programmer",
    "receptionist",
    "mechanic",
    "secretary",
    "king",
    "queen",
    "prince",
    "princess",
]

word_to_idx = {w: i for i, w in enumerate(words)}

# Plant a gender bias signal
# "male" words get positive first dimension, "female" get negative
male_words = ["he", "him", "his", "man", "boy", "father", "king", "prince"]
female_words = [
    "she",
    "her",
    "hers",
    "woman",
    "girl",
    "mother",
    "queen",
    "princess",
]

for w in male_words:
    if w in word_to_idx:
        embeddings[word_to_idx[w], 0] += 1.5

for w in female_words:
    if w in word_to_idx:
        embeddings[word_to_idx[w], 0] -= 1.5

# Occupations with planted stereotypical bias
male_occupations = ["doctor", "engineer", "programmer", "mechanic"]
female_occupations = ["nurse", "teacher", "receptionist", "secretary"]

for w in male_occupations:
    if w in word_to_idx:
        embeddings[word_to_idx[w], 0] += 0.8

for w in female_occupations:
    if w in word_to_idx:
        embeddings[word_to_idx[w], 0] -= 0.8

# Normalize all embeddings to unit length
norms = np.linalg.norm(embeddings[: len(words)], axis=1, keepdims=True)
embeddings[: len(words)] = embeddings[: len(words)] / (norms + 1e-8)

Identifying the gender direction. We compute difference vectors for each definitionally gendered pair and run PCA to find the primary direction.

In[5]:
Code
# Definitionally gendered pairs for identifying bias direction
gendered_pairs = [
    ("he", "she"),
    ("him", "her"),
    ("his", "hers"),
    ("man", "woman"),
    ("boy", "girl"),
    ("father", "mother"),
    ("king", "queen"),
    ("prince", "princess"),
]

# Compute difference vectors
difference_vectors = []
for male_word, female_word in gendered_pairs:
    if male_word in word_to_idx and female_word in word_to_idx:
        male_vec = embeddings[word_to_idx[male_word]]
        female_vec = embeddings[word_to_idx[female_word]]
        diff = male_vec - female_vec
        difference_vectors.append(diff)

difference_matrix = np.array(difference_vectors)

# PCA to find the primary gender direction
pca = PCA(n_components=1)
pca.fit(difference_matrix)
gender_direction = pca.components_[0]  # Shape: (embed_dim,)
gender_direction = gender_direction / np.linalg.norm(gender_direction)
Out[6]:
Console
Gender direction identified from 8 pairs
First PC explains 25.4% of variance in difference vectors
Gender direction vector (first 5 dims): [ 0.116 -0.04   0.166  0.13  -0.034]

The first principal component captures the dominant axis of variation across all gendered pairs. A high explained variance means the pairs agree strongly on a single underlying direction. In practice on real GloVe vectors, the first PC typically explains 60-70% of variance, which confirms that a single gender dimension is a useful approximation even though the full bias structure is higher-dimensional.

Measuring pre-debiasing bias. Before modifying anything, let us quantify the current bias by measuring how strongly occupation words project onto the gender direction.

In[7]:
Code
occupations = [
    "doctor",
    "nurse",
    "engineer",
    "teacher",
    "programmer",
    "receptionist",
    "mechanic",
    "secretary",
]

# Gender projection: positive = male-associated, negative = female-associated
projections_before = {}
for occ in occupations:
    if occ in word_to_idx:
        vec = embeddings[word_to_idx[occ]]
        proj = np.dot(vec, gender_direction)
        projections_before[occ] = proj
Out[8]:
Console
Gender projections (before debiasing):
Occupation        Projection    Direction
------------------------------------------
mechanic               0.207 male-associated
receptionist           0.176 male-associated
doctor                 0.168 male-associated
nurse                  0.144 male-associated
teacher                0.086 male-associated
secretary             -0.034      neutral
programmer            -0.055 female-associated
engineer              -0.077 female-associated

Applying hard debiasing. Now we neutralize the occupation words by projecting out their gender component.

In[9]:
Code
# Neutral words: these should not carry gendered associations
neutral_words = [
    "doctor",
    "nurse",
    "engineer",
    "teacher",
    "programmer",
    "receptionist",
    "mechanic",
    "secretary",
]

# Store debiased embeddings
debiased_embeddings = embeddings.copy()

for word in neutral_words:
    if word in word_to_idx:
        idx = word_to_idx[word]
        vec = debiased_embeddings[idx]

        # Project out the gender component
        # vec_debiased = vec - (vec · g) * g
        gender_projection = np.dot(vec, gender_direction)
        debiased_embeddings[idx] = vec - gender_projection * gender_direction

        # Re-normalize to unit length
        norm = np.linalg.norm(debiased_embeddings[idx])
        if norm > 1e-8:
            debiased_embeddings[idx] /= norm

# Measure projections after debiasing
projections_after = {}
for occ in occupations:
    if occ in word_to_idx:
        vec = debiased_embeddings[word_to_idx[occ]]
        proj = np.dot(vec, gender_direction)
        projections_after[occ] = proj
Out[10]:
Console
Gender projections (after debiasing):
Occupation          Before      After     Change
------------------------------------------------
mechanic             0.207      0.000     -0.207
receptionist         0.176      0.000     -0.176
doctor               0.168      0.000     -0.168
nurse                0.144      0.000     -0.144
teacher              0.086      0.000     -0.086
secretary           -0.034      0.000     +0.034
programmer          -0.055     -0.000     +0.055
engineer            -0.077      0.000     +0.077

Average |projection| before: 0.118
Average |projection| after:  0.000
Bias reduction: 100.0%

Visualizing the effect. The following plots show the gender projections before and after debiasing, making the shift concrete.

Out[11]:
Visualization
Bar chart of gender projections for 8 occupations before debiasing, split stereotypically.
Gender-direction projections for eight occupations before hard debiasing. Positive values indicate male association, negative values indicate female association. The occupations cluster into two stereotypically gendered groups, showing the associations present in the training corpus.
Bar chart of gender projections for 8 occupations after debiasing, clustered near zero.
Gender-direction projections after hard debiasing. All occupation vectors are projected orthogonal to the gender direction, collapsing the absolute projection values toward zero while leaving the definitionally gendered words unchanged.

The projections collapse toward zero for all occupation words after debiasing. The key intuition: we have not changed what these word vectors know about the world. "Doctor" still clusters near "hospital", "patient", and "diagnosis". We have only removed the directional pull toward masculine associations.

A second way to visualize this is to project all words into two dimensions and observe how the embedding geometry shifts. In the undebiased space, the first axis of variation separates male- and female-associated words. After debiasing the occupation words, that axis no longer is the primary organizer for those terms, even though the definitionally gendered words (he, she, king, queen) retain their positions.

Out[12]:
Visualization
2D scatter of word embeddings before debiasing, with occupations spread along the horizontal gender direction.
Two-dimensional embedding projection before debiasing. The horizontal axis is the learned gender direction, with male-associated words on the right and female-associated words on the left. Occupation words are distributed along this axis according to their learned gender associations; the vertical axis is the leading principal component orthogonal to gender.
2D scatter after debiasing, with occupation words aligned at zero on the horizontal gender direction.
Two-dimensional embedding projection after hard debiasing of occupation words. The occupation words now align at zero on the horizontal gender axis while retaining their positions on the orthogonal component, and the definitionally gendered words retain their gender-axis positions.