The Big Picture: Evolution of Feature Interaction Learning
The journey from simple linear models to DeepFM represents a fundamental shift in how we capture feature interactions for prediction tasks. Each step in this evolution adds more expressive power.
The Four Eras
The key insight: Feature interactions matter. A linear model treats each feature independently. But in reality, features combine—a user's likelihood to click depends not just on their age and the ad category separately, but on how age and category interact (e.g., young users may prefer tech ads, older users may prefer finance ads).
Recommendation Example: CTR Prediction
Let's use a concrete example throughout this guide: predicting whether a user will click on an ad in an e-commerce or ad platform context.
The Problem
We have millions of ads shown daily. For each impression, we want to predict: will this user click? Features include:
- User features: age, gender, location, historical CTR, device type
- Ad features: category, brand, price range, ad format, creative quality
- Context features: hour of day, day of week, search query, page position
The Challenge: Sparse High-Dimensional Data
After one-hot encoding categorical features (e.g., category has 500 values), we have 10,000+ dimensions. Most features are zeros (sparse). The data matrix is 1,000,000 rows × 10,000 columns, but 99% of entries are zero.
Matrix Factorization: The Foundation
Matrix Factorization (MF) is the workhorse of collaborative filtering. It's the stepping stone to understanding Factorization Machines.
The Core Idea
Given a sparse user-item matrix R (millions of users × thousands of items, but only a tiny fraction of entries observed), we approximate it as:
R ≈ P × Q^T where: P is an (n_users × k) matrix: each user u has a k-dimensional latent vector p_u Q is an (n_items × k) matrix: each item i has a k-dimensional latent vector q_i k is small (e.g., 10–100), the number of latent factors
Making a Prediction
For user u and item i, the predicted rating is the dot product of their latent vectors:
r̂_{ui} = p_u · q_i = Σ_{f=1}^{k} p_{u,f} × q_{i,f}With biases (which improve accuracy), the model becomes:
r̂_{ui} = μ + b_u + b_i + p_u · q_i
where:
μ = global mean rating
b_u = user bias (some users rate high, some rate low)
b_i = item bias (some items are rated high, some low)
p_u · q_i = interaction term (captures user-item affinity in latent space)The Loss Function & Training
We minimize squared error on observed entries:
L = Σ_{(u,i) ∈ observed} (r_{ui} - r̂_{ui})^2 + λ(||P||^2 + ||Q||^2 + b_u^2 + b_i^2)
The λ term is L2 regularization (prevents overfitting).Two common training methods:
- Stochastic Gradient Descent (SGD): For each observed (u, i), compute the error e_{ui} = r_{ui} - r̂_{ui}, then update:
p_u ← p_u + α × (e_{ui} × q_i - λ × p_u) q_i ← q_i + α × (e_{ui} × p_u - λ × q_i) b_u ← b_u + α × (e_{ui} - λ × b_u) b_i ← b_i + α × (e_{ui} - λ × b_i) - Alternating Least Squares (ALS): Fix P, solve for Q in closed form. Then fix Q, solve for P. Repeat until convergence. More stable, parallelizable.
Concrete Example: A Small Rating Matrix
Why MF Works with Sparse Data
Instead of learning one parameter per user-item pair (n_users × n_items = millions of parameters for large systems), we learn two small matrices (n_users × k + n_items × k ≈ thousands of parameters). Latent factors enable generalization: if user U1 and U3 have similar latent vectors, we can infer that U1 might like items U3 likes, even without direct evidence.
SVD Decomposition: Classical Matrix Decomposition
Singular Value Decomposition (SVD) is the classical approach to matrix decomposition, and it provides important intuition for understanding FM.
Full SVD Definition
For any matrix R (m × n), SVD decomposes it as:
R = U × Σ × V^T where: U (m × m): orthogonal matrix of left singular vectors (user space) Σ (m × n diagonal): singular values (importance of each component) V (n × n): orthogonal matrix of right singular vectors (item space)
The singular values σ₁ ≥ σ₂ ≥ ... ≥ σᵣ > 0 are sorted in decreasing order. The first k singular values capture most of the variance.
Truncated SVD: Dimensionality Reduction
To reduce noise and improve generalization, we keep only the top k singular values:
R_k = U_k × Σ_k × V_k^T where U_k, Σ_k, V_k contain only the top k components. This is a rank-k approximation: R_k has much lower rank than full R.
SVD vs Matrix Factorization: Key Difference
- SVD: Computes on the full matrix R (including zeros and unobserved entries). Expensive for large sparse matrices. Suitable for dense, complete data.
- MF (Funk SVD): Only optimizes on observed entries. Uses SGD or ALS. Efficient for sparse data. This is what Netflix Prize algorithms used.
Visualizing Truncation
Connection to Matrix Factorization
MF is essentially a form of approximate SVD optimized for sparse data. When we compute P × Q^T, we're constructing a low-rank approximation to R. The latent factors are analogous to the left and right singular vectors (scaled by singular values).
Factorization Machines: Generalizing MF
Factorization Machines (FM) are the breakthrough that connects matrix factorization to general feature learning. They solve the fundamental limitation of MF: how to incorporate arbitrary features, not just user and item IDs.
The Problem FM Solves
Suppose we have n features (after one-hot encoding). A linear model is:
ŷ(x) = w₀ + Σᵢ wᵢxᵢ
To capture feature interactions, we'd add polynomial terms:
ŷ(x) = w₀ + Σᵢ wᵢxᵢ + Σᵢ Σ_{j>i} w_{ij}xᵢxⱼBut this has O(n²) parameters! For n=10,000, that's 50 million parameters just for pairwise interactions. With sparse data, most interactions are never observed in training.
FM's Elegant Solution: Latent Vectors
Instead of learning a separate parameter w_{ij} for each pair, FM learns a small latent vector v_i for each feature i. The interaction between features i and j is:
= Σ_{f=1}^{k} v_{i,f} × v_{j,f} This is the dot product of their latent vectors. Only k parameters per feature!
The full FM equation (degree-2, which is most common):
ŷ(x) = w₀ + Σᵢ wᵢxᵢ + Σᵢ Σ_{j>i} × xᵢ × xⱼ
Parameters:
w₀: 1 parameter (global bias)
w: n parameters (linear weights)
V: n × k parameters (latent vectors)
Total: 1 + n + nk ≈ nk (if k << n) Why This Works: The Latent Factor Trick
Here's the magic: even if features i and j never appear together in training data, their interaction can be estimated because they share the latent space:
- Feature i has latent vector v_i = [0.8, 0.3, ...]
- Feature j has latent vector v_j = [0.7, 0.4, ...]
- Their interaction
= 0.8×0.7 + 0.3×0.4 + ... = 0.68 can be inferred even if (i,j) pair is rare
FM Generalizes Matrix Factorization
This is the key insight: FM is a generalization of MF! If you encode user and item as one-hot features, FM's interaction term becomes exactly the MF equation:
FM: How It Works—Training & Prediction
Computing Predictions Efficiently
Naively, computing the pairwise interactions takes O(n²k):
Σᵢ Σ_{j>i} xᵢ xⱼ = Σᵢ Σ_{j>i} (Σ_{f=1}^{k} v_{i,f} v_{j,f}) xᵢ xⱼ But FM uses a mathematical trick to reduce this to O(kn):
Σᵢ Σ_{j>i} xᵢ xⱼ = ½ Σ_{f=1}^{k} [(Σᵢ v_{i,f} xᵢ)² - Σᵢ v_{i,f}² xᵢ²]
Proof (for single factor f):
(Σᵢ v_{i,f} xᵢ)² = Σᵢ Σⱼ v_{i,f} v_{j,f} xᵢ xⱼ
= Σᵢ v_{i,f}² xᵢ² + 2·Σᵢ Σ_{j>i} v_{i,f} v_{j,f} xᵢ xⱼ
Rearranging:
Σᵢ Σ_{j>i} v_{i,f} v_{j,f} xᵢ xⱼ = ½[(Σᵢ v_{i,f} xᵢ)² - Σᵢ v_{i,f}² xᵢ²] Full FM Prediction Algorithm (O(kn))
def fm_predict(x, w0, w, V):
# Linear term: O(n)
linear = w0 + sum(w[i] * x[i] for i in range(n))
# Quadratic term: O(kn)
interaction = 0
for f in range(k):
# Compute sum of v_{i,f} * x_i for all i
sum_vx = sum(V[i, f] * x[i] for i in range(n))
# Compute sum of v_{i,f}^2 * x_i^2 for all i
sum_vv_xx = sum(V[i, f]**2 * x[i]**2 for i in range(n))
# Add to interaction term
interaction += sum_vx**2 - sum_vv_xx
interaction *= 0.5
return linear + interactionTraining FM with SGD
For a single training sample (x, y), we compute:
ŷ = FM prediction (as above)
error = y - ŷ (or loss gradient, depending on loss function)
Gradients:
∂ŷ/∂w₀ = 1
∂ŷ/∂wᵢ = xᵢ
∂ŷ/∂v_{i,f} = xᵢ · (Σⱼ v_{j,f} xⱼ - v_{i,f} xᵢ)
= xᵢ · (sum_vx - v_{i,f} xᵢ) [from cached computation]
Updates (with learning rate α and regularization λ):
w₀ ← w₀ - α · ∂L/∂w₀
wᵢ ← wᵢ - α · (∂L/∂ŷ · ∂ŷ/∂wᵢ + 2λ·wᵢ)
v_{i,f} ← v_{i,f} - α · (∂L/∂ŷ · ∂ŷ/∂v_{i,f} + 2λ·v_{i,f})Loss functions vary by task:
- Regression (MSE): L = ½(y - ŷ)², ∂L/∂ŷ = -(y - ŷ)
- Binary Classification (Log Loss): L = -[y log(σ(ŷ)) + (1-y) log(1-σ(ŷ))], ∂L/∂ŷ = σ(ŷ) - y
- Ranking (BPR): L = -log(σ(ŷ_pos - ŷ_neg)), optimizes relative ordering
Why FM Works with Sparse Data
Key insight: Each feature only needs k parameters (its latent vector). The interaction between rare feature pairs is estimated through the shared latent space:
- If user_age_25 and item_category_tech rarely co-occur, we can't learn w_{age_25,tech} directly.
- But both features have learned latent vectors (v_age_25 and v_tech).
- Their interaction
is estimated from other feature interactions they participate in. - This is transfer learning in the latent factor space.
Regularization in FM: Preventing Overfitting
The Regularization Challenge
FM has many parameters: 1 + n + nk. For n=10,000 and k=10, that's 100,001 parameters. With sparse data, most features appear in only a few samples. Without regularization, latent vectors for rare features can grow unbounded to fit noise.
L2 Regularization (Most Common)
Add penalty terms to the loss function:
L_total = L_data + λ_w·||w||² + λ_v·||V||²
where:
||w||² = Σᵢ wᵢ²
||V||² = Σᵢ Σ_{f} v_{i,f}²
Typical values:
λ_w: 1e-5 to 1e-3 (smaller, linear terms are robust)
λ_v: 1e-4 to 1e-1 (larger, latent factors need more regularization)L2 regularization shrinks all parameters towards zero, proportional to their magnitude. It prevents extreme latent vector values.
Per-Dimension Regularization
Advanced: use different regularization for each latent dimension:
L_total = L_data + Σ_{f} λ_f·Σᵢ v_{i,f}²
Insight: Some latent dimensions may be noisier than others.
Different λ per dimension allows flexibility.L1 Regularization (Feature Selection)
For linear weights, L1 regularization induces sparsity (some w_i become exactly zero):
L_total = L_data + λ·Σᵢ |wᵢ| Effect: Fewer non-zero linear features → automatic feature selection. Less common for latent factors (L2 preferred).
Impact: Seen vs Unseen Feature Interactions
DeepFM Architecture: Combining FM and Deep Learning
The Motivation: Limitations of FM
FM captures 2nd-order feature interactions explicitly via latent vectors. But real-world patterns often involve higher-order interactions:
- A young female user in San Francisco clicking on ads during lunch hours on weekdays (3rd+ order)
- Specific combinations of user browsing history × product category × time (high-order)
A deep neural network can learn these automatically, but it can't efficiently capture low-order interactions due to its layer-wise computation. DeepFM combines both: explicit low-order (FM) + implicit high-order (Deep).
DeepFM Architecture Diagram
Why Shared Embeddings?
Both components use the same embedding layer. Why?
- Parameter efficiency: Single embedding per feature, not separate ones for FM/Deep
- Joint learning: Embeddings learn representations that satisfy both low-order and high-order patterns
- Interpretability: Shared embeddings mean FM factors and deep layer inputs align semantically
DeepFM vs Other Architectures
Training DeepFM: Joint Optimization
Joint Loss Function
Both FM and Deep components are trained simultaneously with a single loss function:
ŷ = σ(y_FM + y_deep) Loss (for binary classification): L = -[y·log(ŷ) + (1-y)·log(1-ŷ)] + λ_w·||w||² + λ_v·||V||² + λ_net·||θ_deep||² where: y_FM = w₀ + Σᵢ wᵢxᵢ + (FM interaction term) y_deep = output of deep network θ_deep = weights of all layers in deep network λ_net = regularization for deep network parameters
Training Algorithm
Standard gradient descent (typically Adam optimizer):
Algorithm: Training DeepFM
────────────────────────────
1. Initialize:
- Embeddings randomly (uniform or Xavier)
- Linear weights w to zero (or small random)
- Deep network weights (Xavier initialization)
2. For each epoch:
For each batch of samples:
a) Forward pass:
- Compute embeddings for all features
- Compute y_FM (with computational trick for efficiency)
- Compute y_deep (layer-by-layer forward pass)
- Compute ŷ = sigmoid(y_FM + y_deep)
b) Compute loss L on batch
c) Backward pass:
- Compute gradients ∂L/∂θ for all parameters θ
- Gradients flow through both FM and Deep components
- Update shared embeddings based on both components
d) Update parameters:
- θ ← θ - α·∂L/∂θ (Adam with learning rate schedule)
3. Validate on held-out data, early stoppingHyperparameter Tuning
Training Efficiency & Tricks
- Batch normalization: Can stabilize training in deep layers (less critical with dropout)
- Learning rate schedule: Decay LR over epochs (e.g., 0.001 → 0.0001). Helps convergence.
- Early stopping: Monitor validation AUC/loss. Stop when it stops improving for N epochs.
- Data shuffling: Shuffle samples each epoch to avoid batch correlations.
- Feature normalization: For continuous features (if any), normalize to [0,1] or z-score.
- Class balance: If data is imbalanced (e.g., 1% clicks), use weighted loss or over-sampling.
When to Use: MF vs FM vs DeepFM
Decision Tree
Detailed Comparison
Rule of Thumb
- If you have only user/item (no side features) → MF
- If you have diverse features but not billions of samples → FM
- If you have billions of samples and GPU resources → DeepFM
Python Implementation from Scratch
1. Matrix Factorization with SGD
import numpy as np
class MatrixFactorization:
"""Basic MF with SGD training."""
def __init__(self, n_users, n_items, k=10, learning_rate=0.01, reg=0.01):
self.n_users = n_users
self.n_items = n_items
self.k = k
self.lr = learning_rate
self.reg = reg
# Initialize factors
self.P = np.random.randn(n_users, k) * 0.01 # User factors
self.Q = np.random.randn(n_items, k) * 0.01 # Item factors
self.b_u = np.zeros(n_users) # User biases
self.b_i = np.zeros(n_items) # Item biases
self.mu = 0.0 # Global mean
def predict(self, user_id, item_id):
"""Predict rating for user-item pair."""
return self.mu + self.b_u[user_id] + self.b_i[item_id] + np.dot(self.P[user_id], self.Q[item_id])
def train(self, user_ids, item_ids, ratings, epochs=10):
"""SGD training."""
self.mu = np.mean(ratings)
for epoch in range(epochs):
total_loss = 0
for u, i, r in zip(user_ids, item_ids, ratings):
pred = self.predict(u, i)
error = r - pred
# Update biases
self.b_u[u] += self.lr * (error - self.reg * self.b_u[u])
self.b_i[i] += self.lr * (error - self.reg * self.b_i[i])
# Update factors
q_copy = self.Q[i].copy()
self.P[u] += self.lr * (error * self.Q[i] - self.reg * self.P[u])
self.Q[i] += self.lr * (error * self.P[u] - self.reg * self.Q[i])
total_loss += error ** 2
print(f"Epoch {epoch+1}: MSE = {total_loss / len(user_ids):.4f}")
# Example usage
np.random.seed(42)
mf = MatrixFactorization(n_users=5, n_items=4, k=2)
# Synthetic training data
user_ids = np.array([0, 0, 1, 1, 2, 2, 3, 3, 4, 4])
item_ids = np.array([0, 2, 1, 3, 0, 1, 0, 2, 1, 2])
ratings = np.array([5, 4, 3, 2, 4, 5, 3, 2, 4, 5])
mf.train(user_ids, item_ids, ratings, epochs=50)2. Factorization Machines for CTR Prediction
class FactorizationMachine:
"""FM for binary classification (CTR prediction)."""
def __init__(self, n_features, k=8, learning_rate=0.01, reg_w=0.0001, reg_v=0.001):
self.n_features = n_features
self.k = k
self.lr = learning_rate
self.reg_w = reg_w
self.reg_v = reg_v
self.w0 = 0.0 # Global bias
self.w = np.zeros(n_features) # Linear weights
self.V = np.random.randn(n_features, k) * 0.01 # Latent factors
def sigmoid(self, x):
return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500)))
def predict(self, x):
"""Efficient O(kn) prediction with computational trick."""
# Linear term
linear = self.w0 + np.dot(self.w, x)
# Quadratic term (O(kn) via the trick)
interaction = 0.0
for f in range(self.k):
sum_vx = np.dot(self.V[:, f], x)
sum_vv_xx = np.dot(self.V[:, f] ** 2, x ** 2)
interaction += sum_vx ** 2 - sum_vv_xx
interaction *= 0.5
logit = linear + interaction
return self.sigmoid(logit)
def train(self, X, y, epochs=20):
"""SGD training for binary classification."""
n_samples = X.shape[0]
for epoch in range(epochs):
total_loss = 0
for idx in range(n_samples):
x = X[idx]
target = y[idx]
pred = self.predict(x)
error = pred - target
# Update w0
self.w0 -= self.lr * error
# Update w (linear weights)
self.w -= self.lr * (error * x + 2 * self.reg_w * self.w)
# Update V (latent factors) - O(kn)
for f in range(self.k):
sum_vx = np.dot(self.V[:, f], x)
for i in range(self.n_features):
if x[i] != 0:
grad = error * x[i] * (sum_vx - self.V[i, f] * x[i])
self.V[i, f] -= self.lr * (grad + 2 * self.reg_v * self.V[i, f])
total_loss += -(target * np.log(pred + 1e-10) + (1 - target) * np.log(1 - pred + 1e-10))
auc = self.evaluate(X, y)
print(f"Epoch {epoch+1}: Loss = {total_loss / n_samples:.4f}, AUC = {auc:.4f}")
def evaluate(self, X, y):
"""Compute AUC."""
preds = np.array([self.predict(x) for x in X])
return np.mean((y == 1) & (preds > 0.5)) / (np.mean(y == 1) + 1e-10)
# Synthetic CTR data
np.random.seed(42)
n_samples = 200
n_features = 20
X = np.random.randint(0, 2, (n_samples, n_features)) # One-hot features
y = (X[:, 0] & X[:, 5] | X[:, 10]).astype(int) # Synthetic label with interactions
y = np.random.binomial(1, 0.3 + 0.4 * y) # Add noise
fm = FactorizationMachine(n_features=n_features, k=4)
fm.train(X, y, epochs=30)3. SimpleDeepFM (Numpy Implementation)
class SimpleDeepFM:
"""DeepFM with single hidden layer (simplified for demo)."""
def __init__(self, n_features, k=8, hidden_units=64, learning_rate=0.01):
self.n_features = n_features
self.k = k
self.lr = learning_rate
# FM component
self.w0 = 0.0
self.w = np.zeros(n_features)
self.V = np.random.randn(n_features, k) * 0.01
# Deep component
self.embedding_dim = k # Use FM embeddings
embedding_size = n_features * k
self.W1 = np.random.randn(embedding_size, hidden_units) * 0.01
self.b1 = np.zeros(hidden_units)
self.W2 = np.random.randn(hidden_units, 1) * 0.01
self.b2 = np.zeros(1)
def relu(self, x):
return np.maximum(0, x)
def relu_grad(self, x):
return (x > 0).astype(float)
def sigmoid(self, x):
return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500)))
def predict(self, x):
"""Forward pass."""
# FM prediction
linear = self.w0 + np.dot(self.w, x)
interaction = 0.0
for f in range(self.k):
sum_vx = np.dot(self.V[:, f], x)
sum_vv_xx = np.dot(self.V[:, f] ** 2, x ** 2)
interaction += sum_vx ** 2 - sum_vv_xx
interaction *= 0.5
y_fm = linear + interaction
# Deep prediction
emb = self.V @ x # Embeddings (k,)
emb_flat = emb.flatten() # Flatten if needed
hidden = self.relu(np.dot(emb_flat, self.W1) + self.b1)
y_deep = np.dot(hidden, self.W2) + self.b2
# Combine
logit = y_fm + y_deep
return self.sigmoid(logit)
def train(self, X, y, epochs=20):
"""Simple SGD training (no proper backprop, just for demo)."""
for epoch in range(epochs):
total_loss = 0
for idx in range(len(X)):
x = X[idx]
target = y[idx]
pred = self.predict(x)
loss = -(target * np.log(pred + 1e-10) + (1 - target) * np.log(1 - pred + 1e-10))
total_loss += loss
# Simple gradient update (not full backprop)
error = pred - target
self.w0 -= self.lr * error
self.w -= self.lr * error * x
print(f"Epoch {epoch+1}: Loss = {total_loss / len(X):.4f}")
# Test on same data
deepfm = SimpleDeepFM(n_features=20, k=4, hidden_units=16)
deepfm.train(X, y, epochs=20)Complete Example: End-to-End CTR Prediction
"""
Complete example: Predicting click probability on ads.
Features: user age (categorical), ad category (categorical), hour of day (categorical)
"""
import pandas as pd
from sklearn.preprocessing import LabelEncoder
# Synthetic CTR dataset
data = {
'user_age': ['18-25', '26-35', '18-25', '36-50', '26-35'] * 20,
'ad_category': ['Tech', 'News', 'Tech', 'Finance', 'News'] * 20,
'hour': [9, 14, 18, 10, 15] * 20,
'clicked': [1, 0, 1, 0, 1] * 20
}
df = pd.DataFrame(data)
# One-hot encoding
le_age = LabelEncoder()
le_cat = LabelEncoder()
le_hour = LabelEncoder()
df['user_age_enc'] = le_age.fit_transform(df['user_age'])
df['ad_category_enc'] = le_cat.fit_transform(df['ad_category'])
df['hour_enc'] = le_hour.fit_transform(df['hour'])
# Create feature matrix (one-hot)
n_age = len(df['user_age'].unique())
n_cat = len(df['ad_category'].unique())
n_hour = len(df['hour'].unique())
n_features = n_age + n_cat + n_hour
X = np.zeros((len(df), n_features))
for idx, row in df.iterrows():
X[idx, row['user_age_enc']] = 1
X[idx, n_age + row['ad_category_enc']] = 1
X[idx, n_age + n_cat + row['hour_enc']] = 1
y = df['clicked'].values
# Train FM
print("=== Training Factorization Machine ===")
fm = FactorizationMachine(n_features=n_features, k=4)
fm.train(X, y, epochs=20)
# Make predictions
print("\n=== Sample Predictions ===")
for idx in range(min(5, len(X))):
pred_prob = fm.predict(X[idx])
print(f"Sample {idx}: Click probability = {pred_prob:.4f}, Actual = {y[idx]}")