import json
import matplotlib.pyplot as plt
from matplotlib.colors import to_rgba, to_hex
import pandas as pd
import numpy as np
import umap
from openai import OpenAI
import warnings
# Suppress all UserWarnings because umap is annoying
"ignore", category=UserWarning) warnings.filterwarnings(
Leon Yin, Davey Alba and Leonardo Nicoletti with the Bloomberg Graphics team put together an incredible piece on racial bias in ChatGPT by measuring how OpenAI’s model ranked resume’s for different jobs. Using name as signal, they found very strong race and gender effects that varied by occupation. I suspect that an expanded replication would be a great fit with many top social science journals.
One part that interested me was the figure they created that showed how OpenAI’s word embeddings cluster names by race
I wanted to recreate that. Fortunately, they posted their code on GitHub. The word embeddings didn’t run as written, but I was able to get it up an running with a few modifications.
They studied a now slightly-out-of-date embedding model, text-embedding-ada-002
, so I also wanted to extend their work to look at the new models text-embedding-3-small
and text-embedding-3-large.
def load_and_prep_names(file_path, sex):
= pd.read_json(file_path)
df = pd.melt(df, var_name="race", value_name="name")
melted_df =True)
melted_df.dropna(inplace=True, inplace=True)
melted_df.reset_index(drop"sex"] = sex
melted_df[return melted_df
# File paths
= "https://raw.githubusercontent.com/BloombergGraphics/2024-openai-gpt-hiring-racial-discrimination/main/data/input/top_mens_names.json"
fn_names_men = "https://raw.githubusercontent.com/BloombergGraphics/2024-openai-gpt-hiring-racial-discrimination/main/data/input/top_womens_names.json"
fn_names_women
# Load and prepare data
= load_and_prep_names(fn_names_men, "M")
names_men = load_and_prep_names(fn_names_women, "W")
names_women
# Combine and title-case names - Original used all upper case
= pd.concat([names_men, names_women])
name_df "name"] = name_df["name"].str.title()
name_df[
# Now, name_df is ready and contains the processed names with races and sex indicators.
5) name_df.sample(
race | name | sex | |
---|---|---|---|
114 | B | Darnell Jackson | M |
59 | W | Katie Schwartz | W |
42 | W | Jaclyn Reilly | W |
304 | H | Alex Barajas | M |
107 | B | Ayanna Singleton | W |
Next, I get the embeddings for the three models of interest. text-embedding-ada-002
and text-embedding-3-small
but represent texts in 1,536 dimensions, while text-embedding-3-large
has twice as many. This is hard to plot, so, following the good folks at Bloomberg, I use UMAP to reduce this down to just two dimensions for each embedding model. I also tried a different algorithm, TSNE with very similar results.
def get_open_embeddings(text_list, model="text-embedding-3-small"):
= OpenAI(max_retries=3)
client """Fetches embeddings for a list of texts using the specified model."""
= client.embeddings.create(input=text_list, model=model)
embeddings_response return [text.embedding for text in embeddings_response.data]
def reduce_dimensions(
=2, random_state=42, n_neighbors=30, min_dist=0.9
df, embedding_column, n_components
):"""Reduces the dimensions of embeddings to 2D using UMAP."""
= np.vstack(df[embedding_column])
embeddings = umap.UMAP(
reducer =n_components,
n_components=random_state,
random_state=n_neighbors,
n_neighbors=min_dist,
min_dist
)= reducer.fit_transform(embeddings)
reduced_embeddings f"{embedding_column}_umap_x"], df[f"{embedding_column}_umap_y"] = (
df[
reduced_embeddings.T
)return df
# Main code
for model in [
"text-embedding-ada-002",
"text-embedding-3-small",
"text-embedding-3-large",
]:
if model not in name_df.keys():
= name_df["name"].tolist()
names = get_open_embeddings(names, model=model)
name_df[model] = reduce_dimensions(name_df, model) name_df
Finally, plot them. I added gender to the mix by showing women’s names with a darker hue.
# Function to darken a color
def darken_color(color, factor=0.7):
# Convert to RGBA if it's a named color or in hex format
= to_rgba(color)
rgba_color # Darken by reducing the brightness
= [factor * rgb for rgb in rgba_color[:3]] + [rgba_color[3]]
darkened return to_hex(darkened)
# Base colors for races
= {
race2color "W": "#c71e1d",
"B": "#fa8c00",
"A": "#009076",
"H": "#15607a",
"CTRL": "grey",
}
def plot_names(model):
# Extend race2color with darker shades for females
= {race: darken_color(color) for race, color in race2color.items()}
race2color_female
# Extract UMAP coordinates, names, races, and sexes
= name_df[f"{model}_umap_x"].values
umap_x = name_df[f"{model}_umap_y"].values
umap_y = name_df["name"].values
names = name_df["race"].values
races = name_df["sex"].values
sexes
# Determine color based on race and sex
= [
colors if sex == "W" else race2color[race]
race2color_female[race] for race, sex in zip(races, sexes)
]
# Create the plot
= plt.subplots(figsize=(16, 16))
fig, ax =colors, alpha=0.5)
ax.scatter(umap_x, umap_y, color
# Annotate each point with the corresponding name
for x, y, name, color in zip(umap_x, umap_y, names, colors):
=color, size=8, ha="center", va="bottom")
ax.annotate(name, (x, y), color
# Enhancements
f"UMAP projection of {model} Embeddings by Race and Sex", fontsize=16)
ax.set_title(True, which="both", linestyle="--", linewidth=0.5)
ax.grid(True)
ax.set_axisbelow(
# Optional: Remove the x and y axis ticks
ax.set_xticks([])
ax.set_yticks([])
plt.show()
for model in ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large']:
plot_names(model)
All three models show racial clusters with very few outliers. I suspect the differences in the distances between the clusters is a function of the UMAP process of reducing 1K+ dimensions down to two, but maybe not. One notable difference is that while all three clustered the South Asian names together, the text-embedding-3-large
had the Patels and Singhs off on their own.
Notably, the names aren’t clustering by gender to the same degree as race. It appears to me that the last names are running the show in two dimensions. I haven’t test this, but it does look like there is some clustering by gender within racial clusters, although this varies by embedding model and by racial group.
I thought it would be fruitful to look at using the name embeddings as features in machine learning to predict race, but, after running the model a few times, I realized this was the wrong dataset to test that out on. While it has 800 unique cases, it only has 20 unique last names for each racial group, so it’s really easy to overfit the model. But since I put together the code here it is:
from sklearn.model_selection import train_test_split
from sklearn.svm import SVC
from sklearn.metrics import classification_report
import numpy as np
import pandas as pd
# Assuming 'name_df' is your DataFrame and it already includes the embedding features
# and the 'race' column.
# Function to train and evaluate an SVM classifier for a given set of embeddings
def train_and_evaluate_svm(df, feature_column, target_column):
# Split data into features (X) and target (y)
= np.vstack(df[feature_column]) # Ensure the embeddings are in the correct format
X = df[target_column].values
y
# Split the dataset into training and testing sets
= train_test_split(X, y, test_size=0.2, random_state=42)
X_train, X_test, y_train, y_test
# Initialize and train the SVM classifier
= SVC(kernel='linear', C=1.0, random_state=42)
clf
clf.fit(X_train, y_train)
# Predict on the test set
= clf.predict(X_test)
y_pred
# Evaluate the classifier
= classification_report(y_test, y_pred)
report return report
# List of embedding columns in your DataFrame
= ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large']
embedding_columns
# Evaluate the classifier for each set of embeddings
for embedding in embedding_columns:
print(f"Evaluating SVM with embeddings: {embedding}")
= train_and_evaluate_svm(name_df, embedding, 'race')
report print(report)
Evaluating SVM with embeddings: text-embedding-ada-002
precision recall f1-score support
A 1.00 1.00 1.00 42
B 1.00 0.97 0.99 35
H 1.00 1.00 1.00 46
W 0.97 1.00 0.99 37
accuracy 0.99 160
macro avg 0.99 0.99 0.99 160
weighted avg 0.99 0.99 0.99 160
Evaluating SVM with embeddings: text-embedding-3-small
precision recall f1-score support
A 1.00 1.00 1.00 42
B 1.00 1.00 1.00 35
H 1.00 1.00 1.00 46
W 1.00 1.00 1.00 37
accuracy 1.00 160
macro avg 1.00 1.00 1.00 160
weighted avg 1.00 1.00 1.00 160
Evaluating SVM with embeddings: text-embedding-3-large
precision recall f1-score support
A 1.00 1.00 1.00 42
B 1.00 1.00 1.00 35
H 1.00 1.00 1.00 46
W 1.00 1.00 1.00 37
accuracy 1.00 160
macro avg 1.00 1.00 1.00 160
weighted avg 1.00 1.00 1.00 160
Back when I thought it would be useful, I also tried to model how many embedding dimensions it would take, leveraging the fact that the two new OpenAI models apparently put all the interesting ones up from. Results do seem to confirm that, but again, with only 20 last names, I don’t make too much of this.
from sklearn.model_selection import train_test_split
from sklearn.svm import SVC
from sklearn.metrics import accuracy_score
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# Assuming 'name_df' is your DataFrame and it includes both the embedding feature
# 'text-embedding-3-small' and the 'race' column.
# Function to train and evaluate an SVM classifier for a given number of embeddings
def train_and_evaluate_svm(df, embeddings_column, target_column, max_columns):
# Initialize list to store accuracy scores
= []
accuracies = []
columns_used
for num_embeddings in range(5, max_columns + 1, 1):
# Extract the first 'num_embeddings' from each row in the embeddings column
= np.array(df[embeddings_column].apply(lambda x: x[:num_embeddings]).tolist())
X = df[target_column].values
y
# Split the dataset into training and testing sets
= train_test_split(X, y, test_size=0.3)
X_train, X_test, y_train, y_test
# Initialize and train the SVM classifier
= SVC(kernel='linear', C=1.0, random_state=42)
clf
clf.fit(X_train, y_train)
# Predict on the test set
= clf.predict(X_test)
y_pred
# Calculate and store the accuracy
= accuracy_score(y_test, y_pred)
accuracy
accuracies.append(accuracy)
columns_used.append(num_embeddings)
return accuracies, columns_used
# Assuming 'text-embedding-3-small' contains embeddings and 'race' is the target column
# Also assuming each embedding in 'text-embedding-3-small' has at least 200 dimensions
=(10, 6))
plt.figure(figsize
= ['b', 'g', 'r'] # Define a list of colors if you want specific colors for each model
colors = ['text-embedding-ada-002','text-embedding-3-small', 'text-embedding-3-large']
model_names # Alternatively, remove the colors list and Matplotlib will automatically assign colors.
for i, model in enumerate(model_names):
= train_and_evaluate_svm(name_df, model, 'race', 200)
accuracies, columns_used
# Plotting the accuracy vs. number of embeddings used
# If using automatic colors, just remove the color argument
='o', linestyle='-', label=model)
plt.plot(columns_used, accuracies, marker
'SVM Classifier Accuracy vs Number of Embeddings Used')
plt.title('Number of Embeddings Used')
plt.xlabel('Accuracy')
plt.ylabel(True)
plt.grid(# This adds the legend using the labels specified in plt.plot()
plt.legend() plt.show()
Future directions: * Use a different dataset for racial classification by name. * Subtract the overall average name vectors from the average name vector by race to get a race vector? * Redo the Bloomberg audi study, but use ChatGPT to create the resumes, and then rank them, either with different names or without names.