前言
在上一篇文章中,我們探討了使用圖技術構建推薦系統的理論基礎,本篇文章將以一個電影數據集作為 sample dataset 進行實作,並將實作內容分為兩個部分:
- 本篇主要聚焦於計算電影之間的相似度,結合用戶行為數據來構建相似度矩陣。
- 下一篇將進一步實作如何將相似度數據導入到 Neo4j,利用圖數據庫的強大功能構建推薦系統。
那我們開始吧~
Recap: 為什麼要建構電影與用戶行為相似度矩陣
電影與用戶行為的相似度矩陣可以視為一種特徵工程,類似於在機器學習模型中進行特徵建置的過程。通過構建這樣的矩陣,我們能有效量化電影間的關聯性與用戶偏好,為推薦系統提供核心數據基礎。建構電影與用戶行為相似度矩陣的四步驟
可以將流程分為四個步驟:
- 資料預覽(Preview Data): 檢視現有的資料集,了解資料的基本結構和內容,例如電影的類型、評分及標籤等,以便確認後續處理的方向。
- 資料預處理(Preprocess): 根據電影相似度計算的需求,對資料進行清洗和整理,包括處理缺失值、拆分類型欄位等,為計算餘弦相似度做好準備。
- 計算餘弦相似度(Calculate Cosine Similarity): 將整理好的電影數據輸入餘弦相似度公式,計算每部電影與其他電影之間的相似度,生成電影相似度矩陣。
- 結果推論(Inference): 根據電影 ID 查詢相似度矩陣,輸出與該電影最相似的前五部電影作為推薦結果。

Photo by Andrew Neel on Unsplash
1.資料預覽(Preview Data)
本實作選用 MovieLens 25M Dataset,這是一個電影推薦相關的資料集,包含用戶評分和用戶標籤數據,是研究推薦系統的經典資料集之一。
資料內容概覽
- 數據規模:
- 評分數據:共 25,000,095 條評分記錄
- 標籤數據:共 1,093,360 條用戶標籤應用
- 電影數量:59047 部電影
- 用戶數量:162,541 位用戶
2. 時間範圍:覆蓋 1995 年 1 月 9 日至 2019 年 11 月 21 日,長達 24 年的數據記錄。
3. 數據來源:用戶是隨機選擇的,每位用戶至少評分過 20 部電影
檔案結構
資料集由多個 CSV 文件組成,分別記錄了電影、用戶行為及相關特徵,具體包括:
genome-scores.csv
和genome-tags.csv
:包含標籤相關的數據,如標籤與電影的相關性分數movies.csv
:記錄電影的基本資訊(如電影標題和類型)ratings.csv
:包含電影的用戶評分數據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 部,僅保留這些電影的相關數據。
縮小範圍的具體步驟:
- 隨機抽取 1000 部電影的
movieId
。 - 根據選定的
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
進行前處理,通過結合電影的年份、評分數量和平均評分來構建更具分析價值的數據結構
- 提取電影年份並分組
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)
- 電影簡介:青春洋溢的艾維斯從海軍退伍,憑著母親的遺物,決心尋找從未謀面的父親。滿心期待的他在德州南部一個寧靜的港市找到生父,一位受人尊敬的牧師,但早已另築家庭。當艾維斯說明來意之後,面對的卻是冰冷的拒絕,於是他決定不計任何代價,爭取做為「兒子」應有的權利,將他過去被剝奪的,全數討回,除了善用天使般的微笑之外,他決定和撒旦打交道…
結果說明
目前的測試結果顯示,推薦效果可能顯得普通。這其中的一個主要原因是在實驗中縮小了資料庫中的電影數量,這直接影響了推薦系統能提供的電影選擇範圍。之所以採取這樣的方式,主要是受運算過程中記憶體限制的影響。
這種縮小資料庫的策略雖然解決了記憶體不足的問題,但也暴露了當前算法的一些缺點。特別是稀疏矩陣的使用會導致記憶體的高度耗用,從而限制了算法的擴展性和計算效率。針對這些問題,推薦系統逐漸演化出了更多不同的方法,例如採用降維技術或基於深度學習的推薦算法,這些方法在提升運算效率的同時,也能有效處理記憶體消耗的問題。
未來的改進方向包括:
- 資料的處理與壓縮:透過降維技術(如SVD、PCA)減少矩陣的稀疏性,從而降低記憶體的需求。
- 採用更高效的推薦算法:例如基於圖神經網路(Graph Neural Networks, GNN)的推薦系統,或者結合深度學習的嵌入技術(Embeddings),可以在更大的資料庫上進行快速計算。
- 優化記憶體管理:採用分散式存儲或計算架構(如Spark或Hadoop),能有效解決單機記憶體的限制問題。
- 混合推薦模型:結合基於內容(Content-based)和基於協同過濾(Collaborative Filtering)的方法,提升推薦的多樣性和準確性。
這些改進不僅能解決記憶體限制的問題,也能提高算法的推薦效果和運算效率,為用戶提供更加精確且豐富的電影推薦選擇。
結語
透過本篇文章,我們完整地實作了基於相似度矩陣的電影推薦系統,從資料預處理到餘弦相似度的計算,再到最終生成推薦結果。下一篇預告:
在下一篇文章中,我們將進一步探討如何利用 Neo4j 這一圖數據庫的強大功能來構建推薦系統。
感謝讀到這裡的你,我們下次見!