电影评分的 k 均值聚类

假设你是 Netflix 的一名数据分析师,你想要根据用户对不同电影的评分研究用户在电影品位上的相似和不同之处。了解这些评分对用户电影推荐系统有帮助吗?我们来研究下这方面的数据。

我们将使用的数据来自精彩的 MovieLens 用户评分数据集。我们稍后将在 notebook 中查看每个电影评分,先看看不同类型之间的评分比较情况。

数据集概述

该数据集有两个文件。我们将这两个文件导入 pandas dataframe 中:


In [1]:
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
from scipy.sparse import csr_matrix
import helper

# Import the Movies dataset
movies = pd.read_csv('ml-latest-small/movies.csv')
movies.head()


Out[1]:
movieId title genres
0 1 Toy Story (1995) Adventure|Animation|Children|Comedy|Fantasy
1 2 Jumanji (1995) Adventure|Children|Fantasy
2 3 Grumpier Old Men (1995) Comedy|Romance
3 4 Waiting to Exhale (1995) Comedy|Drama|Romance
4 5 Father of the Bride Part II (1995) Comedy

In [2]:
# Import the ratings dataset
ratings = pd.read_csv('ml-latest-small/ratings.csv')
ratings.head()


Out[2]:
userId movieId rating timestamp
0 1 31 2.5 1260759144
1 1 1029 3.0 1260759179
2 1 1061 3.0 1260759182
3 1 1129 2.0 1260759185
4 1 1172 4.0 1260759205

现在我们已经知道数据集的结构,每个表格中有多少条记录。


In [ ]:
print('The dataset contains: ', len(ratings), ' ratings of ', len(movies), ' movies.')

爱情片与科幻片

我们先查看一小部分用户,并看看他们喜欢什么类型的电影。我们将大部分数据预处理过程都隐藏在了辅助函数中,并重点研究聚类概念。在完成此 notebook 后,建议你快速浏览下 helper.py,了解这些辅助函数是如何实现的。


In [3]:
# Calculate the average rating of romance and scifi movies

genre_ratings = helper.get_genre_ratings(ratings, movies, ['Romance', 'Sci-Fi'], ['avg_romance_rating', 'avg_scifi_rating'])
genre_ratings.head()


Out[3]:
avg_romance_rating avg_scifi_rating
userId
1 3.50 2.40
2 3.59 3.80
3 3.65 3.14
4 4.50 4.26
5 4.08 4.00

函数 get_genre_ratings 计算了每位用户对所有爱情片和科幻片的平均评分。我们对数据集稍微进行偏倚,删除同时喜欢科幻片和爱情片的用户,使聚类能够将他们定义为更喜欢其中一种类型。


In [4]:
biased_dataset = helper.bias_genre_rating_dataset(genre_ratings, 3.2, 2.5)

print( "Number of records: ", len(biased_dataset))
biased_dataset.head()


Number of records:  183
Out[4]:
userId avg_romance_rating avg_scifi_rating
0 1 3.50 2.40
1 3 3.65 3.14
2 6 2.90 2.75
3 7 2.93 3.36
4 12 2.89 2.62

可以看出我们有 183 位用户,对于每位用户,我们都得出了他们对看过的爱情片和科幻片的平均评分。

我们来绘制该数据集:


In [5]:
%matplotlib inline

helper.draw_scatterplot(biased_dataset['avg_scifi_rating'],'Avg scifi rating', biased_dataset['avg_romance_rating'], 'Avg romance rating')


我们可以在此样本中看到明显的偏差(我们故意创建的)。如果使用 k 均值将样本分成两组,效果如何?


In [6]:
# Let's turn our dataset into a list
X = biased_dataset[['avg_scifi_rating','avg_romance_rating']].values
  • 导入 KMeans
  • 通过 n_clusters = 2 准备 KMeans
  • 将数据集 X 传递给 KMeans 的 fit_predict 方法,并将聚类标签放入 predictions

In [8]:
# TODO: Import KMeans
from  sklearn.cluster import KMeans

# TODO: Create an instance of KMeans to find two clusters
kmeans_1 = KMeans(n_clusters=2)

# TODO: use fit_predict to cluster the dataset
predictions =  kmeans_1.fit_predict(X)

# Plot
helper.draw_clusters(biased_dataset, predictions)


可以看出分组的依据主要是每个人对爱情片的评分高低。如果爱情片的平均评分超过 3 星,则属于第一组,否则属于另一组。

如果分成三组,会发生什么?


In [ ]:
# TODO: Create an instance of KMeans to find three clusters
kmeans_2 = KMeans(n_clusters=3)

# TODO: use fit_predict to cluster the dataset
predictions_2 = kmeans_2.fit_predict(X)

# Plot
helper.draw_clusters(biased_dataset, predictions_2)

现在平均科幻片评分开始起作用了,分组情况如下所示:

  • 喜欢爱情片但是不喜欢科幻片的用户
  • 喜欢科幻片但是不喜欢爱情片的用户
  • 即喜欢科幻片又喜欢爱情片的用户

再添加一组


In [ ]:
# TODO: Create an instance of KMeans to find four clusters
kmeans_3 =  KMeans(n_clusters=3)

# TODO: use fit_predict to cluster the dataset
predictions_3 = kmeans_3.fit

# Plot
helper.draw_clusters(biased_dataset, predictions_3)

可以看出将数据集分成的聚类越多,每个聚类中用户的兴趣就相互之间越相似。

选择 K

我们可以将数据点拆分为任何数量的聚类。对于此数据集来说,正确的聚类数量是多少?

可以通过多种方式选择聚类 k。我们将研究一种简单的方式,叫做“肘部方法”。肘部方法会绘制 k 的上升值与使用该 k 值计算的总误差分布情况。

如何计算总误差? 一种方法是计算平方误差。假设我们要计算 k=2 时的误差。有两个聚类,每个聚类有一个“图心”点。对于数据集中的每个点,我们将其坐标减去所属聚类的图心。然后将差值结果取平方(以便消除负值),并对结果求和。这样就可以获得每个点的误差值。如果将这些误差值求和,就会获得 k=2 时所有点的总误差。

现在的一个任务是对每个 k(介于 1 到数据集中的元素数量之间)执行相同的操作。


In [ ]:
# Choose the range of k values to test.
# We added a stride of 5 to improve performance. We don't need to calculate the error for every k value
possible_k_values = range(2, len(X)+1, 5)

# Calculate error values for all k values we're interested in
errors_per_k = [helper.clustering_errors(k, X) for k in possible_k_values]

In [ ]:
# Optional: Look at the values of K vs the silhouette score of running K-means with that value of k
list(zip(possible_k_values, errors_per_k))

In [ ]:
# Plot the each value of K vs. the silhouette score at that value
fig, ax = plt.subplots(figsize=(16, 6))
ax.set_xlabel('K - number of clusters')
ax.set_ylabel('Silhouette Score (higher is better)')
ax.plot(possible_k_values, errors_per_k)

# Ticks and grid
xticks = np.arange(min(possible_k_values), max(possible_k_values)+1, 5.0)
ax.set_xticks(xticks, minor=False)
ax.set_xticks(xticks, minor=True)
ax.xaxis.grid(True, which='both')
yticks = np.arange(round(min(errors_per_k), 2), max(errors_per_k), .05)
ax.set_yticks(yticks, minor=False)
ax.set_yticks(yticks, minor=True)
ax.yaxis.grid(True, which='both')

看了该图后发现,合适的 k 值包括 7、22、27、32 等(每次运行时稍微不同)。聚类 (k) 数量超过该范围将开始导致糟糕的聚类情况(根据轮廓分数)

我会选择 k=7,因为更容易可视化:


In [ ]:
# TODO: Create an instance of KMeans to find seven clusters
kmeans_4 = 

# TODO: use fit_predict to cluster the dataset
predictions_4 = 

# plot
helper.draw_clusters(biased_dataset, predictions_4, cmap='Accent')

注意:当你尝试绘制更大的 k 值(超过 10)时,需要确保你的绘制库没有对聚类重复使用相同的颜色。对于此图,我们需要使用 matplotlib colormap 'Accent',因为其他色图要么颜色之间的对比度不强烈,要么在超过 8 个或 10 个聚类后会重复利用某些颜色。

再加入动作片类型

到目前为止,我们只查看了用户如何对爱情片和科幻片进行评分。我们再添加另一种类型,看看加入动作片类型后效果如何。

现在数据集如下所示:


In [ ]:
biased_dataset_3_genres = helper.get_genre_ratings(ratings, movies, 
                                                     ['Romance', 'Sci-Fi', 'Action'], 
                                                     ['avg_romance_rating', 'avg_scifi_rating', 'avg_action_rating'])
biased_dataset_3_genres = helper.bias_genre_rating_dataset(biased_dataset_3_genres, 3.2, 2.5).dropna()

print( "Number of records: ", len(biased_dataset_3_genres))
biased_dataset_3_genres.head()

In [ ]:
X_with_action = biased_dataset_3_genres[['avg_scifi_rating',
                                         'avg_romance_rating', 
                                         'avg_action_rating']].values

In [ ]:
# TODO: Create an instance of KMeans to find seven clusters
kmeans_5 = 

# TODO: use fit_predict to cluster the dataset
predictions_5 = 

# plot
helper.draw_clusters_3d(biased_dataset_3_genres, predictions_5)

我们依然分别用 x 轴和 y 轴表示科幻片和爱情片。并用点的大小大致表示动作片评分情况(更大的点表示平均评分超过 3 颗星,更小的点表示不超过 3 颗星 )。

可以看出添加类型后,用户的聚类分布发生了变化。为 k 均值提供的数据越多,每组中用户之间的兴趣越相似。但是如果继续这么绘制,我们将无法可视化二维或三维之外的情形。在下个部分,我们将使用另一种图表,看看多达 50 个维度的聚类情况。

电影级别的聚类

现在我们已经知道 k 均值会如何根据用户的类型品位对用户进行聚类,我们再进一步分析,看看用户对单个影片的评分情况。为此,我们将数据集构建成 userId 与用户对每部电影的评分形式。例如,我们来看看以下数据集子集:


In [ ]:
# Merge the two tables then pivot so we have Users X Movies dataframe
ratings_title = pd.merge(ratings, movies[['movieId', 'title']], on='movieId' )
user_movie_ratings = pd.pivot_table(ratings_title, index='userId', columns= 'title', values='rating')

print('dataset dimensions: ', user_movie_ratings.shape, '\n\nSubset example:')
user_movie_ratings.iloc[:6, :10]

NaN 值的优势表明了第一个问题。大多数用户没有看过大部分电影,并且没有为这些电影评分。这种数据集称为“稀疏”数据集,因为只有少数单元格有值。

为了解决这一问题,我们按照获得评分次数最多的电影和对电影评分次数最多的用户排序。这样可以形成更“密集”的区域,使我们能够查看数据集的顶部数据。

如果我们要选择获得评分次数最多的电影和对电影评分次数最多的用户,则如下所示:


In [ ]:
n_movies = 30
n_users = 18
most_rated_movies_users_selection = helper.sort_by_rating_density(user_movie_ratings, n_movies, n_users)

print('dataset dimensions: ', most_rated_movies_users_selection.shape)
most_rated_movies_users_selection.head()

这样更好分析。我们还需要指定一个可视化这些评分的良好方式,以便在查看更庞大的子集时能够直观地识别这些评分(稍后变成聚类)。

我们使用颜色代替评分数字:


In [ ]:
helper.draw_movies_heatmap(most_rated_movies_users_selection)

每列表示一部电影。每行表示一位用户。单元格的颜色根据图表右侧的刻度表示用户对该电影的评分情况。

注意到某些单元格是白色吗?表示相应用户没有对该电影进行评分。在现实中进行聚类时就会遇到这种问题。与一开始经过整理的示例不同,现实中的数据集经常比较稀疏,数据集中的部分单元格没有值。这样的话,直接根据电影评分对用户进行聚类不太方便,因为 k 均值通常不喜欢缺失值。

为了提高性能,我们将仅使用 1000 部电影的评分(数据集中一共有 9000 部以上)。


In [ ]:
user_movie_ratings =  pd.pivot_table(ratings_title, index='userId', columns= 'title', values='rating')
most_rated_movies_1k = helper.get_most_rated_movies(user_movie_ratings, 1000)

为了使 sklearn 对像这样缺少值的数据集运行 k 均值聚类,我们首先需要将其转型为稀疏 csr 矩阵类型(如 SciPi 库中所定义)。

要从 pandas dataframe 转换为稀疏矩阵,我们需要先转换为 SparseDataFrame,然后使用 pandas 的 to_coo() 方法进行转换。

注意:只有较新版本的 pandas 具有to_coo()。如果你在下个单元格中遇到问题,确保你的 pandas 是最新版本。


In [ ]:
sparse_ratings = csr_matrix(pd.SparseDataFrame(most_rated_movies_1k).to_coo())

我们来聚类吧!

对于 k 均值,我们需要指定 k,即聚类数量。我们随意地尝试 k=20(选择 k 的更佳方式如上述肘部方法所示。但是,该方法需要一定的运行时间。):


In [ ]:
# 20 clusters
predictions = KMeans(n_clusters=20, algorithm='full').fit_predict(sparse_ratings)

为了可视化其中一些聚类,我们需要将每个聚类绘制成热图:


In [ ]:
max_users = 70
max_movies = 50

clustered = pd.concat([most_rated_movies_1k.reset_index(), pd.DataFrame({'group':predictions})], axis=1)
helper.draw_movie_clusters(clustered, max_users, max_movies)

需要注意以下几个事项:

  • 聚类中的评分越相似,你在该聚类中就越能发现颜色相似的垂直线。
  • 在聚类中发现了非常有趣的规律:
    • 某些聚类比其他聚类更稀疏,其中的用户可能比其他聚类中的用户看的电影更少,评分的电影也更少。
    • 某些聚类主要是黄色,汇聚了非常喜欢特定类型电影的用户。其他聚类主要是绿色或海蓝色,表示这些用户都认为某些电影可以评 2-3 颗星。
    • 注意每个聚类中的电影有何变化。图表对数据进行了过滤,仅显示评分最多的电影,然后按照平均评分排序。
    • 能找到《指环王》在每个聚类中位于哪个位置吗?《星球大战》呢?
  • 很容易发现具有相似颜色的水平线,表示评分变化不大的用户。这可能是 Netflix 从基于星级的评分切换到喜欢/不喜欢评分的原因之一。四颗星评分对不同的人来说,含义不同。
  • 我们在可视化聚类时,采取了一些措施(过滤/排序/切片)。因为这种数据集比较“稀疏”,大多数单元格没有值(因为大部分用户没有看过大部分电影)。

预测

我们选择一个聚类和一位特定的用户,看看该聚类可以使我们执行哪些实用的操作。

首先选择一个聚类:


In [ ]:
# TODO: Pick a cluster ID from the clusters above
cluster_number = 

# Let's filter to only see the region of the dataset with the most number of values 
n_users = 75
n_movies = 300
cluster = clustered[clustered.group == cluster_number].drop(['index', 'group'], axis=1)

cluster = helper.sort_by_rating_density(cluster, n_movies, n_users)
helper.draw_movies_heatmap(cluster, axis_labels=False)

聚类中的实际评分如下所示:


In [ ]:
cluster.fillna('').head()

从表格中选择一个空白单元格。因为用户没有对该电影评分,所以是空白状态。能够预测她是否喜欢该电影吗?因为该用户属于似乎具有相似品位的用户聚类,我们可以计算该电影在此聚类中的平均评分,结果可以作为她是否喜欢该电影的合理预测依据。


In [ ]:
# TODO: Fill in the name of the column/movie. e.g. 'Forrest Gump (1994)'
# Pick a movie from the table above since we're looking at a subset
movie_name = 

cluster[movie_name].mean()

这就是我们关于她会如何对该电影进行评分的预测。

推荐

我们回顾下上一步的操作。我们使用 k 均值根据用户的评分对用户进行聚类。这样就形成了具有相似评分的用户聚类,因此通常具有相似的电影品位。基于这一点,当某个用户对某部电影没有评分时,我们对该聚类中所有其他用户的评分取平均值,该平均值就是我们猜测该用户对该电影的喜欢程度。

根据这一逻辑,如果我们计算该聚类中每部电影的平均分数,就可以判断该“品位聚类”对数据集中每部电影的喜欢程度。


In [ ]:
# The average rating of 20 movies as rated by the users in the cluster
cluster.mean().head(20)

这对我们来说变得非常实用,因为现在我们可以使用它作为推荐引擎,使用户能够发现他们可能喜欢的电影。

当用户登录我们的应用时,现在我们可以向他们显示符合他们的兴趣品位的电影。推荐方式是选择聚类中该用户尚未评分的最高评分的电影。


In [ ]:
# TODO: Pick a user ID from the dataset
# Look at the table above outputted by the command "cluster.fillna('').head()" 
# and pick one of the user ids (the first column in the table)
user_id = 

# Get all this user's ratings
user_2_ratings  = cluster.loc[user_id, :]

# Which movies did they not rate? (We don't want to recommend movies they've already rated)
user_2_unrated_movies =  user_2_ratings[user_2_ratings.isnull()]

# What are the ratings of these movies the user did not rate?
avg_ratings = pd.concat([user_2_unrated_movies, cluster.mean()], axis=1, join='inner').loc[:,0]

# Let's sort by rating so the highest rated movies are presented first
avg_ratings.sort_values(ascending=False)[:20]

这些是向用户推荐的前 20 部电影!

练习:

  • 如果聚类中有一部电影只有一个评分,评分是 5 颗星。该电影在该聚类中的平均评分是多少?这会对我们的简单推荐引擎有何影响?你会如何调整推荐系统,以解决这一问题?

关于协同过滤的更多信息

  • 这是一个简单的推荐引擎,展示了“协同过滤”的最基本概念。有很多可以改进该引擎的启发法和方法。为了推动在这一领域的发展,Netflix 设立了 Netflix 奖项 ,他们会向对 Netflix 的推荐算法做出最大改进的算法奖励 1,000,000 美元。
  • 在 2009 年,“BellKor's Pragmatic Chaos”团队获得了这一奖项。这篇论文介绍了他们采用的方式,其中包含大量方法。
  • Netflix 最终并没有使用这个荣获 1,000,000 美元奖励的算法,因为他们采用了流式传输的方式,并产生了比电影评分要庞大得多的数据集——用户搜索了哪些内容?用户在此会话中试看了哪些其他电影?他们是否先看了一部电影,然后切换到了其他电影?这些新的数据点可以提供比评分本身更多的线索。

深入研究

  • 该 notebook 显示了用户级推荐系统。我们实际上可以使用几乎一样的代码进行商品级推荐。例如亚马逊的“购买(评价或喜欢)此商品的客户也购买了(评价了或喜欢)以下商品:” 。我们可以在应用的每个电影页面显示这种推荐。为此,我们只需将数据集转置为“电影 X 用户”形状,然后根据评分之间的联系对电影(而不是用户)进行聚类。
  • 我们从数据集 Movie Lens 中抽取了最小的子集,只包含 100,000 个评分。如果你想深入了解电影评分数据,可以查看他们的完整数据集,其中包含 2400 万个评分。