更新於 2024/12/16閱讀時間約 28 分鐘

用 Graph 技術強化推薦系統 (2): 實作篇 — 相似度矩陣推薦電影

前言

在上一篇文章中,我們探討了使用圖技術構建推薦系統的理論基礎,本篇文章將以一個電影數據集作為 sample dataset 進行實作,並將實作內容分為兩個部分:

  1. 本篇主要聚焦於計算電影之間的相似度,結合用戶行為數據來構建相似度矩陣。
  2. 下一篇將進一步實作如何將相似度數據導入到 Neo4j,利用圖數據庫的強大功能構建推薦系統。

那我們開始吧~

Recap: 為什麼要建構電影與用戶行為相似度矩陣

電影與用戶行為的相似度矩陣可以視為一種特徵工程,類似於在機器學習模型中進行特徵建置的過程。通過構建這樣的矩陣,我們能有效量化電影間的關聯性與用戶偏好,為推薦系統提供核心數據基礎。

建構電影與用戶行為相似度矩陣的四步驟

可以將流程分為四個步驟:

  1. 資料預覽(Preview Data): 檢視現有的資料集,了解資料的基本結構和內容,例如電影的類型、評分及標籤等,以便確認後續處理的方向。
  2. 資料預處理(Preprocess): 根據電影相似度計算的需求,對資料進行清洗和整理,包括處理缺失值、拆分類型欄位等,為計算餘弦相似度做好準備。
  3. 計算餘弦相似度(Calculate Cosine Similarity): 將整理好的電影數據輸入餘弦相似度公式,計算每部電影與其他電影之間的相似度,生成電影相似度矩陣。
  4. 結果推論(Inference): 根據電影 ID 查詢相似度矩陣,輸出與該電影最相似的前五部電影作為推薦結果。

Photo by Andrew Neel on Unsplash

1.資料預覽(Preview Data)

本實作選用 MovieLens 25M Dataset,這是一個電影推薦相關的資料集,包含用戶評分和用戶標籤數據,是研究推薦系統的經典資料集之一。

資料內容概覽

  1. 數據規模
  • 評分數據:共 25,000,095 條評分記錄
  • 標籤數據:共 1,093,360 條用戶標籤應用
  • 電影數量:59047 部電影
  • 用戶數量:162,541 位用戶

2. 時間範圍:覆蓋 1995 年 1 月 9 日至 2019 年 11 月 21 日,長達 24 年的數據記錄。

3. 數據來源:用戶是隨機選擇的,每位用戶至少評分過 20 部電影

檔案結構

資料集由多個 CSV 文件組成,分別記錄了電影、用戶行為及相關特徵,具體包括:

  1. genome-scores.csv 和 genome-tags.csv:包含標籤相關的數據,如標籤與電影的相關性分數
  2. movies.csv:記錄電影的基本資訊(如電影標題和類型)
  3. ratings.csv:包含電影的用戶評分數據
  4. tags.csv:記錄用戶對電影應用的標籤數據

讀取資料集

我們先讀取 MovieLens 25M Dataset 中的主要數據文件,包括:

  • genome-scores.csv:標籤相關性數據
  • movies.csv:電影基本資訊數據
  • ratings.csv:用戶評分數據


tag_df = pd.read_csv('./data/genome-scores.csv')
movie_df = pd.read_csv('./data/movies.csv')
rating_df = pd.read_csv('./data/ratings.csv')

縮小數據範圍

由於後續計算餘弦相似度(Cosine Similarity)時,會生成大規模的稀疏矩陣,佔用大量 RAM。本次實作為了控制內存消耗,限制電影的數量為 1000 部,僅保留這些電影的相關數據。

縮小範圍的具體步驟:

  1. 隨機抽取 1000 部電影的 movieId
  2. 根據選定的 movieId,過濾相關的標籤數據、電影數據和評分數據。
# limit to 1000 movies

movieId_list = movie_df['movieId'].sample(1000)
tag_df = tag_df[tag_df['movieId'].isin(movieId_list)]
movie_df = movie_df[movie_df['movieId'].isin(movieId_list)]
rating_df = rating_df[rating_df['movieId'].isin(movieId_list)]

print(tag_df.shape
,movie_df.shape
,rating_df.shape)

2. 資料預處理(Preprocess)

tag_df 的前處理

在進行後續電影相似度計算前,我們需要對 tag_df(標籤相關性數據)進行處理,以轉換為適合計算的矩陣格式,並補全缺失的數據。以下是具體的步驟與說明:

1. 將數據轉換為透視表格式

首先,我們將 tag_df 中的數據透視為矩陣格式,使每部電影的 movieId 作為行索引,每個標籤的 tagId 作為列,標籤相關性分數(relevance)作為矩陣值。

tag_df = tag_df.pivot(index=['movieId'], columns=['tagId'], values='relevance').reset_index()

2. 補全缺失的電影數據

因為我們限制了電影數量為 1000 部,某些 movieId 在 tag_df 中可能缺失(即:這些電影沒有標籤相關性數據)。為了確保矩陣的完整性,我們需要補全這些缺失的電影數據,並設置所有標籤相關性為 0。

'''

補那些沒有在裡面的 movieiId, tag 關係度都設置為 0'''

missing_movieId_list = list(set(movieId_list)-set(tag_df['movieId'].tolist()))
column_names = list(range(1, 1129))
new_rows = pd.DataFrame(
data=[[0] * len(column_names)] * len(missing_movieId_list),
index=missing_movieId_list,
columns=column_names )

tag_df = pd.concat([tag_df, new_rows])

movie_df 的前處理

在相似度計算中,電影的類型(genres)是重要的特徵之一。因此,我們需要對 movie_df 中的類型數據進行處理,將其轉換為適合餘弦相似度計算的數值矩陣格式。

movie_df 中的 genres 欄位是以 | 分隔的字符串格式(如 "Action|Adventure|Comedy")。我們需要將每種電影類型分解為單獨的欄位,並用 One-Hot Encoding 表示是否包含該類型:

  • 1 表示該電影屬於該類型。
  • 0 表示該電影不屬於該類型。
caculate_genres_df = movie_df['genres'].str.get_dummies(sep='|')
caculate_genres_df.insert(0, 'movieId', movie_df['movieId'])
caculate_genres_df.drop(columns=['(no genres listed)'])
caculate_genres_df.head()

ratings_df 的前處理

我們對 rating_df 進行前處理,通過結合電影的年份、評分數量和平均評分來構建更具分析價值的數據結構

  1. 提取電影年份並分組
title_list = movie_df['title'].tolist()

for i in title_list:
if not '(' in i:
print(i)
def get_title_year(x):
if '(' in x and ')' in x:
if (x.split('(')[1])[0:4].isdigit():
return (x.split('(')[1])[0:4]
else:
return np.nan
else:
return np.nan

caculate_movie_year_df = movie_df.copy()
caculate_movie_year_df['title_year'] = movie_df['title'].apply(lambda x: get_title_year(x) )

2. 觀察年份分佈 for 年份分組

caculate_movie_year_df['title_year'] = pd.to_numeric(caculate_movie_year_df['title_year'], errors='coerce')

caculate_movie_year_df['title_year'].dropna().plot(
kind='hist',
bins=20,
edgecolor='black',
title='Distribution of Movie Release Years'
)

plt.xlabel('Year')
plt.ylabel('Count')
plt.show()

3. 年份分組

print(caculate_movie_year_df['title_year'].min())
print(caculate_movie_year_df['title_year'].max())
print(caculate_movie_year_df['title_year'].median())
num_bins = 5 
bins = np.linspace(caculate_movie_year_df['title_year'].min(), caculate_movie_year_df['title_year'].max(), num_bins + 1)
caculate_movie_year_df['year_group'] = pd.cut(caculate_movie_year_df['title_year'], bins=bins, labels=range(1, num_bins + 1))
caculate_movie_year_df['year_group'] = caculate_movie_year_df['year_group'].cat.add_categories([0]).fillna(0)
caculate_movie_year_df.head(10)

4. 計算評分平均並分組

caculate_rating_mean_df =rating_df.groupby('movieId')['rating'].mean().reset_index(name='rating_mean')
caculate_rating_mean_df.describe()

def rating_mean_group(x):
if x<=2:
# 評分低
return 0
elif x<=3:
# 普通評分
return 1
elif x<=4:
# 高評分
return 2
else:
# 非常高
return 3

caculate_rating_mean_df['rating_mean_group'] = caculate_rating_mean_df['rating_mean'].apply(lambda x:rating_mean_group(x))
caculate_rating_mean_df.head(3)

5. 計算評分次數並分組

caculate_rating_count_df = rating_df.groupby('movieId')['timestamp'].count().reset_index(name='rating_count')

def rating_count_group(x):
if x<2:
# 極少評價的電影
return 0
elif x<=6:
# 少量評價
return 1
elif x<=33:
# 中等評價
return 2
else:
# 大量評價
return 3

caculate_rating_count_df['rating_count_group'] = caculate_rating_count_df['rating_count'].apply(lambda x:rating_count_group(x))
caculate_rating_count_df.head(3)

6. 合併最終數據集

caculate_rating_group_df = caculate_movie_year_df.merge(caculate_rating_count_df, on='movieId', how='left')
caculate_rating_group_df = caculate_rating_group_df.merge(caculate_rating_mean_df, on='movieId', how='left')
caculate_rating_group_df = caculate_rating_group_df[['movieId', 'year_group', 'rating_count_group', 'rating_mean_group']]
caculate_rating_group_df['rating_count_group'] = caculate_rating_group_df['rating_count_group'].fillna(-1)
caculate_rating_group_df['rating_mean_group'] = caculate_rating_group_df['rating_mean_group'].fillna(-1)

caculate_rating_group_df.head(3)

3. 計算餘弦相似度(Calculate Cosine Similarity)

cos similarity 簡介

餘弦相似度(Cosine Similarity)是評估兩個非零向量之間相似程度的指標,通過計算它們之間的夾角餘弦值來衡量。

餘弦相似度的值範圍在 [−1,1][-1, 1][−1,1] 之間,其中:

  • 1 表示兩個向量完全相同。
  • 0 表示兩個向量正交,沒有相似性。
  • -1 表示兩個向量完全相反。

⇒ 用餘弦相似度於衡量電影或用戶之間的相似性

計算已處理的三面向數據的 cos similarity

from sklearn.metrics.pairwise import cosine_similarity
  • tag_df
tag_df = tag_df.set_index('movieId')  
cos_tag = cosine_similarity(tag_df.values)
print(tag_df.shape)
print(cos_tag.shape)
  • genres_df
caculate_genres_df = caculate_genres_df.set_index('movieId') 
cos_genres = cosine_similarity(caculate_genres_df.values)
print(caculate_genres_df.shape)
print(cos_genres.shape)
  • rating_group_df
caculate_rating_group_df = caculate_rating_group_df.set_index('movieId')  
cos_rating = cosine_similarity(caculate_rating_group_df.values)
print(caculate_rating_group_df.shape)
print(cos_rating.shape)

接下來要把這三面向合併前,需要先處理矩陣形狀不一致的問題

由於不同數據集的電影數量可能不一致,導致生成的相似度矩陣形狀不同。我們需要對矩陣進行填充,統一形狀。

  • 計算三個相似度矩陣的最大行列數
max_shape = max(cos_tag.shape[0], cos_genres.shape[0], cos_rating.shape[0])
  • 將較小的矩陣填充為目標大小,缺失部分填充為 0
def pad_matrix(matrix, target_shape):
padded_matrix = np.zeros((target_shape, target_shape)) # 創建目標大小的零矩陣
padded_matrix[:matrix.shape[0], :matrix.shape[1]] = matrix # 將原矩陣填入
return padded_matrix

cos_tag = pad_matrix(cos_tag, max_shape)
cos_genres = pad_matrix(cos_genres, max_shape)
cos_rating = pad_matrix(cos_rating, max_shape)

通過加權的方式做合併

cos = cos_tag * 0.5 + cos_genres * 0.25 + cos_rating * 0.25
print(cos.shape)
print(cos)

這樣我們就完成了相似度矩陣的建立!

4. 結果推論(Inference)

相似度矩陣以 movieId 作為 index ,方便後續好查詢

cols = caculate_genres_df.index.values  
inx = caculate_genres_df.index
movies_sim = pd.DataFrame(cos, columns=cols, index=inx)
movies_sim.head()

根據 movieId 提取相似的電影

# 通用函數:根據 movieId 提取相似的電影
def calculate_similar_movies(movieId, top_n=5, include_details=False, movie_df=None):

df = movies_sim.loc[movies_sim.index == movieId].reset_index(). \
melt(id_vars='movieId', var_name='sim_moveId', value_name='relevance'). \
sort_values('relevance', axis=0, ascending=False)[1:top_n+1]

if include_details and movie_df is not None:
df['sim_moveId'] = df['sim_moveId'].astype(int)
df = movie_df.merge(df, left_on='movieId', right_on='sim_moveId', how='inner'). \
sort_values('relevance', axis=0, ascending=False). \
loc[:, ['movieId_y', 'title', 'genres']]. \
rename(columns={'movieId_y': "movieId"})
return df

# 計算所有電影的相似矩陣
def calculate_all_similarities(top_n=5):
similarity_list = []
for movieId in movies_sim.index.tolist():
similarity_list.append(calculate_similar_movies(movieId, top_n=top_n))
return pd.concat(similarity_list, ignore_index=True)

# 單部電影推薦函數
def movie_recommender(movieId):
return calculate_similar_movies(movieId, top_n=5, include_details=True, movie_df=movie_df)

測試推薦的功能

#get recommendation for movie

choice_movieId = movieId_list.sample(1).iloc[0]
print('choice movie:')
print(movie_df[movie_df['movieId'] == choice_movieId])
print()
print('recommendation:')
print(movie_recommender(choice_movieId))

測試結果

  • 選擇的電影:小黃狗的窩
  • 劇情簡介:小女孩娜莎家裡的狗狗剛剛死了,她和家人們都非常傷心…。 有天,娜莎在蒙古草原上撿到了一隻流浪的小花狗”點點”,便好心將牠帶了回家。但是娜莎的爸爸卻擔心來路不明的狗狗會引來狼群,堅持要把牠送走。雖然爸爸極力反對,娜莎仍決心偷偷收留這隻沒有家的小狗狗,不讓爸爸發現…。草原上的遊牧民族面臨了日漸都市化的衝擊,許多人開始遷居到城市去,娜莎的爸爸也做了這個決定。當蒙古包被拆卸裝上車後,娜莎的爸爸卻留下了最後一根木樁,將”點點”拴在木樁上…。離情依依的娜莎坐在滿載著行李的車上,離開了她從小長大的草原。當被緊緊拴住的”點點”變得越來越渺小時,娜莎的眼眶開始充滿了淚水…。她還會再回到草原上嗎?她能給”點點”一個家、永遠和牠在一起嗎?
  • 推薦的電影:永不妥協
  • 電影簡介:一個改編自 1993 年發生的真實事件,艾琳( 茱莉亞羅伯茲 飾)是一名教育程度不高的單親媽媽,她離過兩次婚,她沒有錢、沒有工作、只有三個年幼的小孩,當她生活陷入困境時,還發生了一場車禍的意外,艾琳請艾德(亞伯特芬妮 飾)擔任她這場官司的辨護律師,不過艾德卻未能為她贏得這場官司的賠償金,於是生活陷入困境的艾琳,強迫艾德雇她做為律師事務所的事務員。當她無意間發現當地的電力公司正在污染公共用水時,基於她正義、積極的個性,她決定挺身而出,調查事情的真相。 艾琳和艾德在調查過程中遭遇種種挫折和危險,但她絕不妥協,為了對抗大企業的污染,她一邊和對方的大律師周遊,一邊搜集電力公司,最後還創下了全美歷史上三億三千三百萬美元的最高庭外和解金額,不但悍了正義,也重新認識了自己
  • 推薦的電影:上帝的私生子The King(2005)
  • 電影簡介:青春洋溢的艾維斯從海軍退伍,憑著母親的遺物,決心尋找從未謀面的父親。滿心期待的他在德州南部一個寧靜的港市找到生父,一位受人尊敬的牧師,但早已另築家庭。當艾維斯說明來意之後,面對的卻是冰冷的拒絕,於是他決定不計任何代價,爭取做為「兒子」應有的權利,將他過去被剝奪的,全數討回,除了善用天使般的微笑之外,他決定和撒旦打交道…


結果說明

目前的測試結果顯示,推薦效果可能顯得普通。這其中的一個主要原因是在實驗中縮小了資料庫中的電影數量,這直接影響了推薦系統能提供的電影選擇範圍。之所以採取這樣的方式,主要是受運算過程中記憶體限制的影響。

這種縮小資料庫的策略雖然解決了記憶體不足的問題,但也暴露了當前算法的一些缺點。特別是稀疏矩陣的使用會導致記憶體的高度耗用,從而限制了算法的擴展性和計算效率。針對這些問題,推薦系統逐漸演化出了更多不同的方法,例如採用降維技術或基於深度學習的推薦算法,這些方法在提升運算效率的同時,也能有效處理記憶體消耗的問題。

未來的改進方向包括:

  1. 資料的處理與壓縮:透過降維技術(如SVD、PCA)減少矩陣的稀疏性,從而降低記憶體的需求。
  2. 採用更高效的推薦算法:例如基於圖神經網路(Graph Neural Networks, GNN)的推薦系統,或者結合深度學習的嵌入技術(Embeddings),可以在更大的資料庫上進行快速計算。
  3. 優化記憶體管理:採用分散式存儲或計算架構(如Spark或Hadoop),能有效解決單機記憶體的限制問題。
  4. 混合推薦模型:結合基於內容(Content-based)和基於協同過濾(Collaborative Filtering)的方法,提升推薦的多樣性和準確性。

這些改進不僅能解決記憶體限制的問題,也能提高算法的推薦效果和運算效率,為用戶提供更加精確且豐富的電影推薦選擇。

結語

透過本篇文章,我們完整地實作了基於相似度矩陣的電影推薦系統,從資料預處理到餘弦相似度的計算,再到最終生成推薦結果。下一篇預告:

在下一篇文章中,我們將進一步探討如何利用 Neo4j 這一圖數據庫的強大功能來構建推薦系統。

感謝讀到這裡的你,我們下次見!

分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.