Published on

构建电影推荐系统:协同过滤从理论到实践

Authors
  • avatar
    Name
    Allen Wang
    Twitter

欢迎体验这场关于协同过滤的机器学习之旅!在这篇博客中,我们将一起构建一个电影推荐系统,探索如何利用用户评分预测他们可能喜欢的电影。通过从理论到代码的逐步拆解,你将学会实现协同过滤算法,并通过代码和可视化直观理解其工作原理。无论你是机器学习新手还是有一定基础的学习者,这篇文章都将为你提供清晰且实用的指导。让我们开始吧!

目录


1 - 推荐系统简介

推荐系统是现代互联网服务的核心,它们通过分析用户行为和偏好,预测用户可能感兴趣的内容。例如:

  • Netflix:根据你的观影历史推荐电影或剧集。
  • Amazon:根据你的购买记录推荐产品。
  • Spotify:根据你的听歌习惯推荐音乐。

推荐系统主要有两种类型:

  • 基于内容的过滤:根据项目的特征(如电影的类型、导演)和用户的历史偏好推荐。
  • 协同过滤:根据用户之间的相似性或项目之间的相似性推荐。
  • 混合系统:结合两者以提高推荐质量。

在这篇文章中,我们将聚焦于协同过滤,具体使用矩阵分解方法,通过用户评分数据学习用户和电影的潜在特征。


2 - 协同过滤基础

协同过滤的核心思想是:如果两个用户对某些电影的评分相似,他们可能对其他电影也有相似的偏好。同样,如果两部电影被相似的用户群体喜欢,它们可能在某些方面相似。

矩阵分解方法

我们将使用矩阵分解来实现协同过滤。假设我们有一个评分矩阵,其中:

  • 行表示电影(如《指环王》)。
  • 列表示用户(如你或你的朋友)。
  • 每个单元格是用户对电影的评分(0.5 到 5 分,0 表示未评分)。

这个矩阵通常是稀疏的,因为用户只评分了少量电影。我们的目标是“填补”这些空白,预测用户对未评分电影的评分。

为此,我们将:

  • 为每部电影学习一个特征向量 x(i)\mathbf{x}^{(i)}, 表示电影的潜在特征(如“奇幻程度”或“动作元素”)。
  • 为每个用户学习一个参数向量 w(j)\mathbf{w}^{(j)} 和偏置 b(j)b^{(j)}, 表示用户的偏好。
  • 预测用户 jj 对电影 ii 的评分为: y^(i,j)=w(j)x(i)+b(j)\hat{y}(i,j) = \mathbf{w}^{(j)} \cdot \mathbf{x}^{(i)} + b^{(j)}

符号表

以下是本文使用的关键符号:

符号描述Python 变量
r(i,j)r(i,j)如果用户 jj 评分了电影 ii,则为 1,否则为 0R[i,j]
y(i,j)y(i,j)用户 jj 对电影 ii 的评分(如果已评分)Y[i,j]
w(j)\mathbf{w}^{(j)}用户 jj 的参数向量W[j,:]
b(j)b^{(j)}用户 jj 的偏置b[0,j]
x(i)\mathbf{x}^{(i)}电影 ii 的特征向量X[i,:]
nun_u用户数量num_users
nmn_m电影数量num_movies
nn特征数量num_features
X\mathbf{X}电影特征矩阵X
W\mathbf{W}用户参数矩阵W
b\mathbf{b}用户偏置向量b
R\mathbf{R}评分指示矩阵R

3 - MovieLens 数据集

我们将使用 MovieLens 小型数据集,这是一个广泛用于推荐系统研究的基准数据集。它包含:

  • 443 名用户
  • 4778 部电影
  • 超过 10 万条评分,评分范围为 0.5 到 5(步长为 0.5)。

数据结构

数据集包括两个主要文件:

  • ratings.csv:包含 userIdmovieIdratingtimestamp
  • movies.csv:包含 movieIdtitle

我们将主要使用评分数据,构造以下矩阵:

  • Y:评分矩阵,Y[i,j]Y[i,j] 是用户 jj 对电影 ii 的评分,未评分处为 0。
  • R:指示矩阵,R[i,j]=1R[i,j] = 1 表示用户 jj 评分了电影 ii,否则为 0。

加载数据

以下代码展示如何使用 pandas 加载和预处理数据:

Python
import pandas as pd
import numpy as np

# 加载评分数据
ratings = pd.read_csv('path/to/ratings.csv')

# 转换为用户-电影矩阵
df = pd.pivot_table(ratings, index='movieId', columns='userId', values='rating')

# 创建 Y 和 R 矩阵
Y = df.fillna(0).values  # 评分矩阵,未评分处为 0
R = df.notna().astype(int).values  # 指示矩阵,1 表示已评分

数据探索

让我们检查数据的形状和一些统计信息:

Python
print("Y shape:", Y.shape)  # (4778, 443)
print("R shape:", R.shape)  # (4778, 443)
print("Average rating for movie 1:", np.mean(Y[0, R[0, :].astype(bool)]), "/ 5")

示例输出:

JavaScript
Y shape: (4778, 443)
R shape: (4778, 443)
Average rating for movie 1: 3.4 / 5

这表明我们有 4778 部电影和 443 名用户,评分矩阵非常稀疏,因为用户只评分了少量电影。


4 - 协同过滤算法实现

协同过滤的目标是学习电影特征向量 x(i)\mathbf{x}^{(i)}、用户参数向量 w(j)\mathbf{w}^{(j)} 和偏置 b(j)b^{(j)},使预测评分尽可能接近实际评分。

4.1 成本函数

成本函数衡量预测评分与实际评分的差异,定义为:

  • 误差项:所有已评分电影的预测误差平方和。
  • 正则化项:防止过拟合,惩罚过大的参数值。

数学上,成本函数为: J=12_(i,j):r(i,j)=1(w(j)x(i)+b(j)y(i,j))2+λ2(jk(w_k(j))2+ik(x_k(i))2)J = \frac{1}{2} \sum\_{(i,j): r(i,j)=1} (\mathbf{w}^{(j)} \cdot \mathbf{x}^{(i)} + b^{(j)} - y^{(i,j)})^2 + \frac{\lambda}{2} \left( \sum_j \sum_k (\mathbf{w}\_k^{(j)})^2 + \sum_i \sum_k (\mathbf{x}\_k^{(i)})^2 \right) 其中 λ\lambda 是正则化参数。

练习 1:实现成本函数

以下是使用 for 循环实现的成本函数:

Python
def cofi_cost_func(X, W, b, Y, R, lambda_):
    """
    计算协同过滤的成本函数。

    参数:
        X: 电影特征矩阵 (n_m x n)
        W: 用户参数矩阵 (n_u x n)
        b: 用户偏置向量 (1 x n_u)
        Y: 用户评分矩阵 (n_m x n_u)
        R: 指示矩阵 (n_m x n_u)
        lambda_: 正则化参数

    返回:
        J: 总成本
    """
    nm, nu = Y.shape
    J = 0

    # 计算误差项
    for j in range(nu):
        w = W[j, :]
        b_j = b[0, j]
        for i in range(nm):
            x = X[i, :]
            y = Y[i, j]
            r = R[i, j]
            J += r * (np.dot(w, x) + b_j - y) ** 2

    J = J / 2  # 除以 2
    J += (lambda_ / 2) * (np.sum(W ** 2) + np.sum(X ** 2))  # 正则化

    return J

代码解释:

  • nm, nu:电影和用户数量。
  • 双重循环遍历所有用户和电影,仅当 R[i,j]=1R[i,j] = 1 时计算误差。
  • np.dot(w, x) + b_j:预测评分。
  • (np.dot(w, x) + b_j - y) ** 2:平方误差。
  • r * ...:仅累加已评分项。
  • J / 2:误差项除以 2。
  • (lambda_ / 2) * (np.sum(W ** 2) + np.sum(X ** 2)):正则化项。

向量化实现

for 循环在大型数据集上效率较低。我们使用 TensorFlow 实现向量化版本:

Python
import tensorflow as tf

def cofi_cost_func_v(X, W, b, Y, R, lambda_):
    """
    使用 TensorFlow 的向量化成本函数。
    """
    j = (tf.linalg.matmul(X, tf.transpose(W)) + b - Y) * R
    J = 0.5 * tf.reduce_sum(j ** 2) + (lambda_ / 2) * (tf.reduce_sum(X ** 2) + tf.reduce_sum(W ** 2))
    return J

向量化解释:

  • tf.linalg.matmul(X, tf.transpose(W)):计算所有预测评分的矩阵。
  • + b:广播偏置到每个用户。
  • * R:仅保留已评分项。
  • tf.reduce_sum(j ** 2):计算所有误差的平方和。
  • 正则化项使用 tf.reduce_sum(X ** 2)tf.reduce_sum(W ** 2)

5 - 训练电影推荐模型

我们使用梯度下降最小化成本函数,学习 X\mathbf{X}W\mathbf{W}b\mathbf{b}。由于模型不是标准神经网络,我们使用 TensorFlow 的自定义训练循环。

归一化评分

在训练前,归一化评分以使每部电影的评分均值为 0:

Python
def normalizeRatings(Y, R):
    """
    归一化评分,减去每部电影的平均评分。
    """
    Ymean = (R * Y).sum(1) / R.sum(1)
    Ymean = Ymean.reshape(-1, 1)
    Ynorm = Y - Ymean
    return Ynorm, Ymean
Python
Ynorm, Ymean = normalizeRatings(Y, R)

初始化参数

随机初始化 X\mathbf{X}W\mathbf{W}b\mathbf{b}

Python
num_movies, num_users = Y.shape
num_features = 100  # 特征数量

tf.random.set_seed(1234)
X = tf.Variable(tf.random.normal((num_movies, num_features), dtype=tf.float64), name='X')
W = tf.Variable(tf.random.normal((num_users, num_features), dtype=tf.float64), name='W')
b = tf.Variable(tf.random.normal((1, num_users), dtype=tf.float64), name='b')

训练循环

使用 Adam 优化器和 GradientTape 进行梯度下降:

Python
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-1)
iterations = 200
lambda_ = 1

for iter in range(iterations):
    with tf.GradientTape() as tape:
        cost_value = cofi_cost_func_v(X, W, b, Ynorm, R, lambda_)

    grads = tape.gradient(cost_value, [X, W, b])
    optimizer.apply_gradients(zip(grads, [X, W, b]))

    if iter % 20 == 0:
        print(f"训练损失(迭代 {iter}):{cost_value.numpy():0.1f}")

示例输出:

JavaScript
训练损失(迭代 0):2321191.3
训练损失(迭代 20):136168.7
...
训练损失(迭代 180):2902.1

成本逐渐下降,表明模型正在学习更好的参数。


6 - 生成推荐

训练完成后,我们可以为新用户生成推荐。以下是为自己添加评分并生成推荐的步骤。

添加新用户评分

假设你为一些电影评分(评分范围 0.5 到 5):

Python
movieList, movieList_df = load_Movie_List_pd()  # 假设加载电影列表
my_ratings = np.zeros(num_movies)

# 示例评分
my_ratings[2700] = 5  # Toy Story 3 (2010)
my_ratings[2609] = 2  # Persuasion (2007)
my_ratings[929] = 5   # Lord of the Rings: The Return of the King
my_ratings[246] = 5   # Shrek (2001)
my_ratings[2716] = 3  # Inception
my_ratings[1150] = 5  # The Incredibles (2004)
my_ratings[382] = 2   # Amelie
my_ratings[366] = 5   # Harry Potter and the Sorcerer's Stone
my_ratings[622] = 5   # Harry Potter and the Chamber of Secrets
my_ratings[988] = 3   # Eternal Sunshine of the Spotless Mind
my_ratings[2925] = 1  # Louis Theroux: Law & Disorder
my_ratings[2937] = 1  # Nothing to Declare
my_ratings[793] = 5   # Pirates of the Caribbean

my_rated = [i for i in range(len(my_ratings)) if my_ratings[i] > 0]

更新数据集

将新用户评分添加到 YYRR

Python
Y = np.c_[my_ratings, Y]
R = np.c_[(my_ratings != 0).astype(int), R]
Ynorm, Ymean = normalizeRatings(Y, R)

重新训练模型

使用更新后的数据集重新初始化和训练模型:

Python
num_movies, num_users = Y.shape
num_features = 100

tf.random.set_seed(1234)
X = tf.Variable(tf.random.normal((num_movies, num_features), dtype=tf.float64), name='X')
W = tf.Variable(tf.random.normal((num_users, num_features), dtype=tf.float64), name='W')
b = tf.Variable(tf.random.normal((1, num_users), dtype=tf.float64), name='b')

optimizer = tf.keras.optimizers.Adam(learning_rate=1e-1)
for iter in range(iterations):
    with tf.GradientTape() as tape:
        cost_value = cofi_cost_func_v(X, W, b, Ynorm, R, lambda_)
    grads = tape.gradient(cost_value, [X, W, b])
    optimizer.apply_gradients(zip(grads, [X, W, b]))
    if iter % 20 == 0:
        print(f"训练损失(迭代 {iter}):{cost_value.numpy():0.1f}")

预测评分

使用训练好的参数预测所有评分:

Python
p = np.matmul(X.numpy(), np.transpose(W.numpy())) + b.numpy()
pm = p + Ymean  # 恢复均值
my_predictions = pm[:, 0]  # 新用户的预测评分

生成推荐

按预测评分排序,推荐未评分的电影:

Python
ix = tf.argsort(my_predictions, direction='DESCENDING')
for i in range(17):
    j = ix[i]
    if j not in my_rated:
        print(f'预测评分 {my_predictions[j]:0.2f}{movieList[j]}')

示例输出:

JavaScript
预测评分 4.49:My Sassy Girl (2001)
预测评分 4.48:Memento (2000)
预测评分 4.47:Laggies (2014)
...

比较预测与实际评分

检查模型对已评分电影的预测准确性:

Python
print('\n原始评分 vs 预测评分:\n')
for i in range(len(my_ratings)):
    if my_ratings[i] > 0:
        print(f'原始 {my_ratings[i]},预测 {my_predictions[i]:0.2f}{movieList[i]}')

示例输出:

JavaScript
原始 5.0,预测 4.90:Shrek (2001)
原始 5.0,预测 4.84:Harry Potter and the Sorcerer's Stone
原始 2.0,预测 2.13:Amelie
...

过滤高质量推荐

为了确保推荐的电影更可靠,我们可以过滤掉评分数量少的电影:

Python
filter = (movieList_df["number of ratings"] > 20)
movieList_df["pred"] = my_predictions
movieList_df = movieList_df.reindex(columns=["pred", "mean rating", "number of ratings", "title"])
movieList_df.loc[ix[:300]].loc[filter].sort_values("mean rating", ascending=False)

示例输出:

电影ID预测评分平均评分评分数量标题
17434.034.25107Departed, The (2006)
21123.994.24149Dark Knight, The (2008)
2114.484.12159Memento (2000)
...............

这表明模型推荐了高评分且受欢迎的电影,如《The Departed》和《The Dark Knight》。

6.1 调参建议(新手常见问题)

如果你发现推荐结果“不像自己会喜欢的电影”,通常不是模型失效,而是超参数还没调好。最常见的三个旋钮是:

  • num_features(隐向量维度):太小会欠拟合,太大容易过拟合。
  • lambda_(正则化强度):太小会记住噪声,太大又会抹平个性。
  • learning_rate(学习率):太大损失震荡,太小收敛过慢。

建议使用以下调参顺序:

  1. 先固定 num_features=32,搜索 lambda_(如 0.001 ~ 0.1
  2. 再固定 lambda_,调 learning_rate
  3. 最后再试 num_features(16 / 32 / 64)

6.2 冷启动问题怎么处理?

协同过滤最怕“没有历史数据”:

  • 新用户冷启动:让用户先打 5~10 部电影的分,快速建立初始偏好向量。
  • 新电影冷启动:用内容特征(类型、演员、简介 embedding)做初始向量。

工程上常见做法是 混合推荐

JavaScript
最终分数 = α * 协同过滤分 + (1-α) * 内容模型分

这样即使用户或电影很新,也不会“完全没有推荐”。

6.3 交互式可视化探索

下面 3 个交互组件分别帮助你理解“向量空间”“训练动态”“排序逻辑”:


7 - 总结

恭喜你完成了这场协同过滤的实践之旅!通过这篇文章,你学会了:

  • 协同过滤如何通过矩阵分解预测用户评分。
  • 如何使用 MovieLens 数据集构建推荐系统。
  • 实现和优化协同过滤的成本函数。
  • 使用 TensorFlow 训练模型并生成个性化推荐。

下一步

  • 尝试调整超参数(如特征数量 num_features 或学习率)。
  • 探索冷启动问题(新用户或新电影的推荐)。
  • 研究更高级的推荐技术,如深度学习或混合推荐系统。

想深入学习?查看以下资源:

希望这篇文章为你打开了推荐系统的大门!快去为你的朋友推荐一部电影吧!🎬✨

完整代码已开源在GitHub仓库

本篇文章的部分内容和思想参考了 吴恩达 (Andrew Ng)Coursera 机器学习课程 中的讲解,感谢他对机器学习领域的卓越贡献。