Text Embeddings: From Words to Vectors#
This notebook builds intuition around text embeddings — the core representation used in Chen, Kelly, and Xiu (2022) to turn financial news into predictive features.
Topics covered:
Why embeddings? From bag-of-words to LLM representations
Key terminology: embeddings, cosine similarity, dimension
Your first embedding: inspecting a single vector
Batching and pairwise similarity
Word analogies via vector arithmetic
Embedding real RavenPack headlines and visualizing with PCA
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import plotly.express as px
import plotly.io as pio
import polars as pl
from openai import OpenAI
from sklearn.decomposition import PCA
from sklearn.metrics.pairwise import cosine_similarity
from settings import config
pio.renderers.default = "notebook"
client = OpenAI(api_key=config("OPENAI_API_KEY"))
EMBEDDING_MODEL = "text-embedding-3-small"
DATA_DIR = Path(config("DATA_DIR"))
Why Embeddings?#
Text is not numerical. To feed news articles into a statistical model we need a mapping text → dense vector. The history of that mapping, in brief:
Era |
Method |
Key idea |
|---|---|---|
Classical |
Bag-of-Words / TF-IDF |
Count word frequencies; sparse, high-dimensional |
~2003 |
LDA (Blei, Ng, Jordan) |
Probabilistic topic model; lower-dimensional but still “topics” |
2013 |
Word2Vec (Mikolov et al.) |
Neural net learns dense word vectors; analogies emerge |
2018+ |
LLM Embeddings (BERT, GPT) |
Contextual representations; full-sentence embeddings |
Connection to CKX (Section 2): The paper constructs a feature matrix \(\mathbf{X}\) of dimension \(D \times P\), where each of the \(D\) news articles is represented by a \(P\)-dimensional embedding vector. This is the input to Eq. 2 (ridge regression for return prediction).
Model choice: We use text-embedding-3-small ($0.02 / 1M tokens) for
this demo. CKX use text-embedding-3-large for the full replication.
Terminology#
Word embedding: A fixed-length vector representing a single word.
Text / sentence embedding: A fixed-length vector representing an entire sentence or document (what we use here).
Cosine similarity: Measures the angle between two vectors:
\[\text{cos}(\mathbf{a}, \mathbf{b}) = \frac{\mathbf{a} \cdot \mathbf{b}}{\|\mathbf{a}\| \, \|\mathbf{b}\|}\]Values range from \(-1\) (opposite) to \(1\) (identical direction).
Embedding dimension: The length of the vector. For
text-embedding-3-smallthis is 1536.
First Embedding#
Let’s embed a single sentence and inspect what comes back.
sentence = "Apple reported record quarterly revenue driven by strong iPhone sales."
response = client.embeddings.create(input=[sentence], model=EMBEDDING_MODEL)
vec = np.array(response.data[0].embedding)
print(f"Dimension: {vec.shape[0]}")
print(f"First 10 values: {vec[:10].round(4)}")
print(f"Token usage: {response.usage.total_tokens}")
Dimension: 1536
First 10 values: [ 0.036 0.0037 -0.0087 0.0404 0.0282 -0.008 -0.0369 0.0477 -0.0211
-0.011 ]
Token usage: 11
The vector has 1536 components — each is a floating-point number, typically small in magnitude. On its own one vector is not very informative; embeddings become useful when we compare vectors.
Batching and Pairwise Similarity#
We embed four sentences in a single API call and compute their pairwise cosine similarities.
sentences = [
"Apple reported record quarterly revenue driven by strong iPhone sales.",
"Tesla shares surged after the company beat earnings expectations.",
"The Federal Reserve held interest rates steady at its latest meeting.",
"Heavy rainfall is expected across the Midwest this weekend.",
]
response = client.embeddings.create(input=sentences, model=EMBEDDING_MODEL)
embeddings = np.array([d.embedding for d in response.data])
print(f"Embedding matrix shape: {embeddings.shape}")
Embedding matrix shape: (4, 1536)
sim_matrix = cosine_similarity(embeddings)
labels = ["Apple revenue", "Tesla earnings", "Fed rates", "Midwest rain"]
sim_df = pl.DataFrame(
{"sentence": labels, **{lab: sim_matrix[:, i].round(3) for i, lab in enumerate(labels)}}
)
sim_df
| sentence | Apple revenue | Tesla earnings | Fed rates | Midwest rain |
|---|---|---|---|---|
| str | f64 | f64 | f64 | f64 |
| "Apple revenue" | 1.0 | 0.45 | 0.153 | 0.123 |
| "Tesla earnings" | 0.45 | 1.0 | 0.207 | 0.093 |
| "Fed rates" | 0.153 | 0.207 | 1.0 | 0.146 |
| "Midwest rain" | 0.123 | 0.093 | 0.146 | 1.0 |
Interpretation: The two stock-earnings sentences (Apple, Tesla) are most similar to each other. The weather sentence is the most distant from everything else. The Fed / rates sentence sits in between — financial but not equity-earnings news.
Word Analogies#
Mikolov et al. (2013) famously showed that word vectors support analogies via arithmetic:
Let’s test this with OpenAI’s text-embedding model. Caveat: these models are optimized for full sentences, not single words, so results may be imperfect — but the intuition still holds.
vocabulary = [
"king", "queen", "man", "woman", "prince", "princess", "boy", "girl",
"doctor", "nurse", "actor", "actress",
"France", "Paris", "Germany", "Berlin", "Italy", "Rome",
"cat", "dog",
]
response = client.embeddings.create(input=vocabulary, model=EMBEDDING_MODEL)
word_vecs = {word: np.array(d.embedding) for word, d in zip(vocabulary, response.data)}
def analogy(a, b, c, word_vecs, top_n=5):
"""Solve: a - b + c = ?
Returns the top_n nearest words (excluding a, b, c) by cosine similarity.
"""
target = word_vecs[a] - word_vecs[b] + word_vecs[c]
exclude = {a, b, c}
candidates = {w: v for w, v in word_vecs.items() if w not in exclude}
sims = {
w: cosine_similarity(target.reshape(1, -1), v.reshape(1, -1))[0, 0]
for w, v in candidates.items()
}
ranked = sorted(sims.items(), key=lambda x: x[1], reverse=True)
return ranked[:top_n]
print("king - man + woman = ?")
for word, score in analogy("king", "man", "woman", word_vecs):
print(f" {word:12s} {score:.4f}")
king - man + woman = ?
queen 0.6161
princess 0.4647
girl 0.4379
prince 0.3906
doctor 0.3420
print("France - Paris + Berlin = ?")
for word, score in analogy("France", "Paris", "Berlin", word_vecs):
print(f" {word:12s} {score:.4f}")
France - Paris + Berlin = ?
Germany 0.6481
Italy 0.5028
Rome 0.2535
doctor 0.2388
woman 0.2376
Even with a sentence-optimized model, the analogy structure is visible: queen should rank near the top for the first analogy, and Germany for the second. Results may not be perfect — single-word embeddings from these models carry less structure than dedicated word2vec models — but the core idea is sound.
Transition to Financial News#
The toy examples above show that embeddings capture semantic relationships. Now let’s apply the same idea to real RavenPack headlines — the actual data used in CKX (2022).
The full dataset has millions of headlines. For this demo we sample 30 to keep API costs negligible and output readable.
rp = (
pl.scan_parquet(DATA_DIR / "ravenpack_djpr_with_permno.parquet")
.filter(pl.col("permno").is_not_null())
.select("headline", "entity_name", "event_sentiment_score")
.collect()
.sample(n=30, seed=42)
)
print(f"Sampled {rp.height} headlines")
rp
Sampled 30 headlines
| headline | entity_name | event_sentiment_score |
|---|---|---|
| str | str | f64 |
| "Texas Capital Bancshares Inc. … | "Texas Capital Bancshares Inc." | 0.44 |
| "WSJ(6/26) Small-Stock Focus: H… | "Hovnanian Enterprises Inc." | null |
| "Cooper Tire & Rubber Company R… | "Cooper Tire & Rubber Co." | 0.61 |
| "Liverpool FC and MBNA Announce… | "Bank of America Corp." | 0.44 |
| "Hatteras Financial Corp. Annou… | "Hatteras Financial Corp." | 0.61 |
| … | … | … |
| "ConocoPhillips to Hold First-Q… | "ConocoPhillips Co." | 0.0 |
| "CBOT Delivery Intentions Break… | "CME Group Inc." | null |
| "PRESS RELEASE: Verizon Wireles… | "Verizon Communications Inc." | 0.44 |
| "PRESS RELEASE: American Scienc… | "American Science & Engineering… | 0.0 |
| "PRESS RELEASE: Petroleum Devel… | "PDC Energy Inc." | 0.0 |
Embed Headlines#
We embed all 30 headlines in a single API call.
headlines = rp["headline"].to_list()
response = client.embeddings.create(input=headlines, model=EMBEDDING_MODEL)
headline_embeddings = np.array([d.embedding for d in response.data])
print(f"Embedding matrix shape: {headline_embeddings.shape}")
print(f"This is our X matrix: D={headline_embeddings.shape[0]}, P={headline_embeddings.shape[1]}")
Embedding matrix shape: (30, 1536)
This is our X matrix: D=30, P=1536
Pairwise Similarity: Most and Least Similar Headlines#
With 30 headlines we get a 30×30 similarity matrix. Let’s find the most-similar and least-similar pairs.
sim = cosine_similarity(headline_embeddings)
# Mask the diagonal (self-similarity = 1)
np.fill_diagonal(sim, -1)
# Most similar pair
i_max, j_max = np.unravel_index(sim.argmax(), sim.shape)
print("MOST SIMILAR PAIR")
print(f" Similarity: {sim[i_max, j_max]:.4f}")
print(f" 1: {headlines[i_max]}")
print(f" 2: {headlines[j_max]}")
# Least similar pair
np.fill_diagonal(sim, 2) # exclude diagonal from min
i_min, j_min = np.unravel_index(sim.argmin(), sim.shape)
print(f"\nLEAST SIMILAR PAIR")
print(f" Similarity: {sim[i_min, j_min]:.4f}")
print(f" 1: {headlines[i_min]}")
print(f" 2: {headlines[j_min]}")
MOST SIMILAR PAIR
Similarity: 0.6011
1: National Instruments Reports Record Revenue -3-
2: NiSource Reports Third Quarter 2012 Earnings -4-
LEAST SIMILAR PAIR
Similarity: 0.1081
1: Cooper Tire & Rubber Company Reports First Quarter Profit on Record Sales
2: GPU Inc./Sting -2: Offer Internet, Intranet Access >GPU
Embeddings group semantically related financial news together: headlines about the same company, sector, or event type are close in vector space, while unrelated headlines are far apart.
PCA Visualization#
We project the 1536-dimensional embeddings down to 2D with PCA to visualize
how headlines cluster. Dots are colored by RavenPack’s event_sentiment_score
(red = negative, yellow = neutral, green = positive).
pca = PCA(n_components=2)
coords = pca.fit_transform(headline_embeddings)
entity_names = rp["entity_name"].to_list()
sentiment_scores = rp["event_sentiment_score"].to_list()
fig = px.scatter(
x=coords[:, 0],
y=coords[:, 1],
color=sentiment_scores,
color_continuous_scale="RdYlGn",
range_color=[-1, 1],
hover_data={"Headline": headlines, "Entity": entity_names, "Sentiment": sentiment_scores},
labels={
"x": f"PC1 ({pca.explained_variance_ratio_[0]:.1%} variance)",
"y": f"PC2 ({pca.explained_variance_ratio_[1]:.1%} variance)",
"color": "Sentiment",
},
title="RavenPack Headlines in Embedding Space (PCA Projection)",
opacity=0.7,
)
fig.update_traces(marker=dict(size=12))
fig.update_layout(width=900, height=650)
fig.show()
print(f"Explained variance ratio: PC1={pca.explained_variance_ratio_[0]:.3f}, PC2={pca.explained_variance_ratio_[1]:.3f}")
Explained variance ratio: PC1=0.090, PC2=0.069
Headlines about similar topics or events cluster together in embedding space. The first two principal components capture only a fraction of the total variance — the full 1536-dimensional space contains much richer structure that downstream models can exploit.
Summary and Next Steps#
What we demonstrated:
Text embeddings map sentences to dense vectors in \(\mathbb{R}^{1536}\)
Cosine similarity captures semantic relatedness
Vector arithmetic supports analogies (king − man + woman ≈ queen)
Real financial headlines cluster by topic/entity in embedding space
Connection to CKX pipeline:
The embedding matrix \(\mathbf{X}\) (\(D \times P\)) is the input to Eq. 2 in the paper — a ridge regression that predicts individual stock returns from news embeddings. The key insight is that embeddings capture the semantic content of news: similar articles produce similar vectors, and this structure is what downstream models exploit to predict returns.
Next step: Embed the full headline dataset and construct the return prediction model.