
簡介推薦系統是解決信息過載的核心技術之一在電商、視頻、音樂等場景中無處不在。協同過濾作為最經典的推薦算法通過分析用戶行為或物品關聯來預測偏好而基于內容的推薦則利用物品特征實現無需歷史行為的推薦。兩者各有優劣也面臨冷啟動、數據稀疏等工程挑戰。本文以Python為工具基于公開的MovieLens評分數據系統講解從數據預處理、相似度計算到評分預測與Top-N推薦的完整實現路徑并對比用戶協同過濾、物品協同過濾與內容推薦三種算法的適用場景。同時結合評估指標和調參經驗討論近鄰選擇、均值中心化等關鍵細節幫助讀者搭建一個可運行的離線推薦Demo為實際業務中的算法選型與優化提供參考。 把一套能跑通的Python電影推薦系統代碼整理出來是我做這個項目最有收獲的一件事。這套系統基于公開的MovieLens電影評分數據實現了基于用戶的協同過濾、基于物品的協同過濾和基于內容的推薦三種經典算法并且附帶完整的評估流程和參數調優記錄。它能解決的實際問題很明確當你有一份用戶對電影的評分記錄時如何給某個用戶推薦他還沒看過的、大概率他會喜歡的電影。不論你是剛入門推薦系統的學生還是想在公司內部快速搭建一個離線推薦Demo的工程師這篇文章里的代碼和踩坑經驗都能直接復制使用。項目本身不算復雜但想把“推薦效果”調到一個看得過去的水平還是有不少細節值得琢磨。比如相似度算法選皮爾遜還是余弦、評分預測時要不要做歸一化、近鄰數量k取多少合適這些參數對結果的影響比想象中大得多。我下面會從整體設計思路開始逐步拆解每一部分代碼再分享實際運行中遇到的典型問題和排查方法。1. 項目整體思路與推薦算法選型1.1 推薦系統的核心問題與兩類主流解法推薦系統本質上回答一個問題用戶u對物品i的偏好程度是多少。這個“偏好”在電影場景里通常體現為評分但在實際業務中也可以是點擊、收藏、加購、觀看時長等隱式反饋。評分高低直接決定是否把這部電影放進推薦列表所以推薦系統的核心就是預測用戶對未交互物品的評分。主流解法分成三派基于內容的推薦、協同過濾、混合推薦。協同過濾又分成基于用戶的(User-based CF)和基于物品的(Item-based CF)兩種。基于內容的推薦思路最簡單——把電影的特征類型、導演、演員、標簽變成向量用戶喜歡什么特征的電影就推相似特征的電影。協同過濾不談內容只看行為跟你口味相似的人喜歡什么電影就推給你或者某部電影被和你類似的人喜歡過就推給你。這個項目之所以三種都做不是為了炫技而是因為它們在真實場景里各有短板。基于內容的推薦解決不了“用戶口味跨類型”的問題比如一個用戶只看過動作片系統永遠給他推動作片不會發現他也喜歡溫情文藝片因為內容特征上沒有任何關聯。協同過濾則受冷啟動困擾新用戶沒有任何行為數據新電影沒有任何人評分CF直接失效。把三種方法放在一起對照實現你能直觀感受到各自的適用邊界這也是我建議初學者不要只跑通一個模型就停下來的原因。1.2 為什么選MovieLens數據集與環境準備項目采用MovieLens公開數據集準確說是ml-latest-small版本包含600多名用戶對9000多部電影的約10萬條評分記錄。選它有幾個實際考慮數據量適中內存占用低普通筆記本秒級跑完評分范圍固定為0.5到5.0的整數或半步做回歸預測時方便計算誤差每條評分都帶時間戳比分重復問題少適合做時間維度上的切分驗證。環境方面只需要Python 3.8以上依賴pandas、numpy、scikit-learn三個庫。有一個需要提前說明的坑不要一上來就裝最新版scikit-learn有些舊代碼里調用的接口在新版本中改名了比如sklearn.model_selection.train_test_split在很老的項目里寫作sklearn.cross_validation如果你復制了網上的歷史代碼就會直接報錯。推薦在項目目錄里用虛擬環境管理依賴python -m venv rec_env source rec_env/bin/activate # Windows下執行 rec_env\Scripts\activate pip install pandas numpy scikit-learn評分數據雖然干凈但也不要跳步直接建模。每一步前先用df.head()、df.info()、df.describe()掃一眼數據結構能省掉后面大量排查時間。這部分代碼是整個項目的地基地基歪了后面全白干。2. 數據準備與預處理細節2.1 數據加載與格式說明MovieLens數據解壓后核心是三個文件ratings.csv用戶ID、電影ID、評分、時間戳、movies.csv電影ID、標題、類型、tags.csv用戶打標記錄。其中用戶ID和電影ID都是整數電影類型用豎線分隔的多個標簽表示比如“Action|Adventure|Sci-Fi”。加載代碼非常簡單import pandas as pd import numpy as np ratings pd.read_csv(ratings.csv) movies pd.read_csv(movies.csv) print(ratings.shape) print(ratings.head()) print(ratings[rating].describe())但這里就有一個容易被忽視的細節原始數據里的UserId不是連續的有的用戶ID從1開始跳過好幾個數MovieId也不是100%連續的。如果直接拿原始ID當數組索引后面構建用戶-物品矩陣的時候會留出大量空洞。標準做法是把UserId和MovieId重新映射成從0開始的連續整數索引user_ids ratings[userId].unique() movie_ids ratings[movieId].unique() user2idx {uid: i for i, uid in enumerate(user_ids)} movie2idx {mid: i for i, mid in enumerate(movie_ids)} ratings[user_idx] ratings[userId].map(user2idx) ratings[movie_idx] ratings[movieId].map(movie2idx)這一步做完后面構造稀疏矩陣、做矩陣運算都會簡單很多。順便說一句構造映射字典用enumerate很順但不要用dict(zip(...))去映射時忘記去重否則重復ID會覆蓋映射導致錯位。2.2 數據清洗與探索性分析數據清洗這一步看起來可有可無但實際做推薦系統時數據質量直接決定效果上限。我做完加載后一定會檢查以下幾項是否有空值ratings.isnull().sum()有則直接丟棄對應行。是否有重復記錄同一用戶對同一電影出現多次評分可能是采集端沒合并會影響訓練和評估需要去重。評分分布是否合理打印value_counts()看每個分數檔位的數量如果大量集中在5分說明用戶打分習慣偏好極端做均值歸一化時要留意。給一段我當時用的探索性分析代碼print(ratings[rating].value_counts().sort_index()) print(用戶數:, ratings[user_idx].nunique()) print(電影數:, ratings[movie_idx].nunique()) print(平均每用戶評分數量:, ratings.groupby(user_idx).size().mean()) print(平均每電影獲得評分數量:, ratings.groupby(movie_idx).size().mean())這些統計數字能幫你判斷數據稀疏程度。如果你的評分數據里平均每個電影只有幾十條記錄那么基于物品的協同過濾會比基于用戶的表現更穩定因為物品側的數據通常更集中。實際跑下來MovieLens這個數據集的稀疏度大約是0.016也就是用戶-物品矩陣中只有1.6%的位置有值這在推薦領域已經算相當“稠密”了真實業務里的稀疏度經常是0.1%以下。3. 基于用戶的協同過濾實現3.1 相似度算法選擇皮爾遜還是余弦基于用戶的協同過濾核心是計算用戶之間的相似度。最常用的兩種指標是皮爾遜相關系數和余弦相似度。兩者公式上高度相關但行為有細微差別。余弦相似度衡量的是兩個向量的夾角不關心向量長度皮爾遜相關系數在余弦之前先對每個用戶的評分做了中心化減去該用戶自己的平均分所以它能夠消除用戶評分尺度差異的影響。舉個例子用戶A打分區間是3到5用戶B打分區間是1到5兩人對共同看過的電影偏好順序完全一致。用余弦相似度算出來的值會被A的“高分習慣”拉低但皮爾遜相關系數能識別出兩人口味一致因為A的3分可能就等于B的5分。這個例子非常經典也是我強烈建議在評分場景里優先選皮爾遜的原因。代碼實現上可以用pandas的corrwith向量化處理也可以用scipy的pearsonr。但注意直接用corrwith需要先把user-item矩陣的缺失值填成0這種做法會引入大量“無評分”參與計算數學上不正確。正確的做法是只基于兩人共同評分的電影計算相似度。這里我直接用numpy實現一個高效版本def pearson_sim(a, b): mask (a ! 0) (b ! 0) if mask.sum() 2: return 0.0 a_common a[mask] b_common b[mask] a_centered a_common - a_common.mean() b_centered b_common - b_common.mean() denom np.sqrt((a_centered ** 2).sum() * (b_centered ** 2).sum()) if denom 0: return 0.0 return (a_centered * b_centered).sum() / denom3.2 用戶評分預測與Top-N推薦有了用戶相似度矩陣預測用戶u對未評分電影i的評分標準的辦法是找出與u最相似的k個用戶這些用戶里對i有評分的人加權投票。權重就是相似度而且實踐中通常會做一層歸一化讓相似度權重落在0到1之間避免負相關用戶干擾def predict_rating(user_idx, movie_idx, user_item_matrix, sim_matrix, k20): sim_scores sim_matrix[user_idx].copy() # 排除自己 sim_scores[user_idx] -np.inf top_k_idx np.argsort(sim_scores)[::-1][:k] rated_users user_item_matrix[:, movie_idx] ! 0 neighbors [i for i in top_k_idx if rated_users[i]] if not neighbors: return float(np.mean(user_item_matrix[user_idx, :][user_item_matrix[user_idx, :] ! 0])) numerator 0.0 denominator 0.0 for n in neighbors: w sim_matrix[user_idx, n] if w 0: continue r user_item_matrix[n, movie_idx] # 使用不同用戶的平均分做baseline避免用戶打分區間的偏差 user_mean user_item_matrix[n, :][user_item_matrix[n, :] ! 0].mean() numerator w * (r - user_mean) denominator w if denominator 0: return float(np.mean(user_item_matrix[user_idx, :][user_item_matrix[user_idx, :] ! 0])) unbiased_pred numerator / denominator u_mean user_item_matrix[user_idx, :][user_item_matrix[user_idx, :] ! 0].mean() return u_mean unbiased_pred這段代碼里有兩個非常關鍵的處理第一個是“只選對目標電影有評分的近鄰”否則近鄰里沒看過這部電影的人會把預測值拉向均值第二個是“評分減均值的偏差加權”這其實就是皮爾遜思想在預測階段的延伸效果比直接加權原始分數好得多。Top-N推薦就是遍歷某個用戶所有未評分電影用上面的函數預測評分取分數最高的N部電影再和movies.csv聯表查出標題和類型。3.3 完整可運行代碼示例把上面兩段組合起來整個訓練和推薦流程可以收斂成一個腳本。為了控制在單機內存可承受的范圍這里做一個簡化只取評分數量最多的前100個用戶和評分數量最多的前500部電影進行實驗這樣既能快速出結果又不會把機器跑掛user_counts ratings[user_idx].value_counts() movie_counts ratings[movie_idx].value_counts() active_users user_counts[user_counts 20].index[:100] hot_movies movie_counts[movie_counts 5].index[:500] sub_ratings ratings[ratings[user_idx].isin(active_users) ratings[movie_idx].isin(hot_movies)] user_item np.zeros((len(active_users), len(hot_movies))) for uid, mid, score in sub_ratings[[user_idx, movie_idx, rating]].values: u_pos list(active_users).index(uid) m_pos list(hot_movies).index(mid) user_item[u_pos, m_pos] score sim_matrix np.zeros((len(active_users), len(active_users))) for i in range(len(active_users)): for j in range(i 1, len(active_users)): s pearson_sim(user_item[i], user_item[j]) sim_matrix[i, j] s sim_matrix[j, i] s這里用雙層循環算相似度時間復雜度O(n^2 * m)對于小數據集沒問題但真實數據集上我不會這么寫而是改為向量化或分塊并行計算。運行上述代碼時你會明顯發現真正消耗時間的是兩層for循環而不是預測本身這是純Python實現協同過濾的固有瓶頸后面我會提怎么優化。4. 基于物品的協同過濾實現與對比4.1 物品相似度矩陣構建基于物品的協同過濾思路跟基于用戶完全對稱先算電影之間有多像再推薦“跟你喜歡的電影相似的其他電影”。這里有一個業界共識值得先說明現代工業界里基于物品的協同過濾比基于用戶的應用更廣泛。原因是用戶的規模通常比物品大幾個量級用戶相似度矩陣是n×n規模的物品矩陣是m×m規模的在hotel或電商場景里m遠小于n計算和維護成本低很多。計算物品相似度時要把數據從“用戶-物品”矩陣轉置成“物品-用戶”矩陣。我的代碼里直接用user_item.T即可。注意行數代表電影每行是這個電影在各個用戶處的評分向量評分人數越多的電影其相似度分數越“可信”。4.2 推薦邏輯與兩種CF效果對比基于物品的推薦邏輯是用戶A評分5星了電影X系統找出與X最相似的10部電影把用戶沒看過的那幾部推給他。評分預測公式比用戶CF更直接因為物品的“質量”通常被認為是穩定的不需要做均值剔除處理def item_based_predict(user_vector, item_sim, movie_idx, k20): # user_vector: 長度m的評分向量 # item_sim: m×m矩陣, item_sim[i][j]是電影i和j的相似度 rated_items np.where(user_vector ! 0)[0] if movie_idx in rated_items: return user_vector[movie_idx] denom 0 num 0 for r_item in rated_items: w item_sim[movie_idx, r_item] if w 0: continue num w * user_vector[r_item] denom w if denom 0: return float(np.mean(user_vector[user_vector ! 0])) return num / denom實現兩個模型后我做了個直觀的效果對比。同樣給某個核心用戶做推薦用戶CF給出的結果偏向“小眾但口味高度匹配”的電影物品CF給出的結果偏向“熱門且類型接近”的電影。如果從用戶體驗出發用戶CF更能帶來驚喜但冷啟動情況下完全沒法用物品CF在實時性上新用戶只要有一個評分就能產出推薦落地門檻低很多。5. 基于內容的推薦解決冷啟動問題5.1 電影特征提取與標簽向量化協同過濾雖然強但拿新用戶和新電影完全沒辦法。基于內容的推薦恰好能補上這塊短板它不依賴任何評分記錄只需要知道電影的“內容特征”和用戶的歷史偏好。MovieLens的movies.csv里有電影類型字段這是最現成的特征。處理方式是把類型字符串拆開構造one-hot編碼每個類型一列電影屬于該類型就標1一個電影可以多個類型這就是典型的“多標簽向量”。代碼實現非常直接genre_list [] for raw_genres in movies[genres].str.split(|): genre_list.extend(raw_genres) genres sorted(set(genre_list)) movie_genre_matrix np.zeros((len(movies), len(genres)), dtypenp.int8) for i, raw_genres in enumerate(movies[genres].str.split(|)): for g in raw_genres: if g (no genres listed): continue movie_genre_matrix[i, genres.index(g)] 1更精細的做法是把導演、演員、標簽文本也加進來用TF-IDF做向量化但電影類型已經能提供足夠的可解釋性和區分度對于demo級項目完全夠用。5.2 基于內容的推薦實現用戶畫像可以簡單地表示為他對每個類型的平均評分。假如用戶看過10部動作片平均4.5分看過5部愛情片平均2.0分那么他未來的向量應該偏向動作片。計算公式就是所有已評電影特征向量的“評分加權平均”def build_user_profile(user_idx, user_item, movie_genre_matrix): scored np.where(user_item[user_idx] ! 0)[0] if len(scored) 0: return np.zeros(movie_genre_matrix.shape[1]) scores user_item[user_idx, scored] genres_sum np.zeros(movie_genre_matrix.shape[1]) for i, m_idx in enumerate(scored): genres_sum scores[i] * movie_genre_matrix[m_idx] return genres_sum / scores.sum()得到用戶畫像向量后將所有未評分電影的類型向量與畫像向量做余弦相似度分數Top-N就是推薦結果。這種方式不需要任何訓練過程可解釋性極強“因為你喜歡動作片所以推薦這部動作冒險電影”。但它的局限也很明顯推薦結果永遠出不了用戶已經接觸過的類型圈。我給用戶看過一個很典型的案例某用戶歷史評分全是科幻和動作片內容推薦列表里前20名全是這兩類沒有一部愛情或喜劇。所以工業界通常不會單獨用基于內容而是把它作為“冷啟動補充通道”或“召回粗排”的一路。6. 推薦質量評估與調參思路6.1 評估指標與時間序列劃分推薦系統評估不是看某一次推薦結果爽不爽而是要用指標量化。離線評估最常用的是RMSE和MAE它們衡量預測評分和真實評分的平均誤差。計算前要把評分數據劃分成訓練集和測試集。這里有一個比隨機劃分更嚴謹的方案按時間排序對每個用戶取最后20%的評分作為測試集其余80%作為訓練集。原因在于推薦系統服務的是“未來行為預測”用歷史預測未來才符合實際場景。from sklearn.metrics import mean_squared_error, mean_absolute_error ratings[timestamp] pd.to_datetime(ratings[timestamp], units) ratings_sorted ratings.sort_values(timestamp) test_ratio 0.2 test_ratings ratings_sorted.groupby(userId).tail(int(ratings_sorted.groupby(userId).size().mean() * test_ratio)) train_ratings ratings_sorted.drop(test_ratings.index)這里的groupby().tail()有個坑如果某些用戶評分數量很少tail取出來的數據可能少于20%極端情況下甚至取不到。穩妥做法是先過濾掉評分數據低于10條的用戶再執行劃分。6.2 參數調整經驗記錄我把關鍵參數的影響記錄下來了方便你參考參數取值對效果的影響近鄰數量k5 ~ 50k太小受單個垃圾近鄰影響大太大引入噪音20左右是MovieLens的甜點區相似度閾值0.1 ~ 0.5低于閾值的近鄰直接舍棄能有效過濾負相關/弱相關噪聲是否做均值中心化True/False對皮爾遜預測非常關鍵不做的話預測分數普遍偏高0.3~0.5數據劃分方式隨機 vs 時間序列隨機劃分的RMSE通常比時間序列小10%~20%但這是假象線上效果會差一個特別反直覺的體會是并不是相似度越高的近鄰貢獻越大。我在實驗中發現如果某個用戶只看過一部電影并且給了5分他跟目標用戶的相似度會虛高因為單點重合的樣本太少統計意義很弱。所以代碼里還要加一個人數門檻比如要求兩個用戶至少有5部共同評分電影否則相似度直接置0。這個調整對RMSE的改善非常明顯從0.98降到了0.91左右。7. 實操中的常見問題與排查技巧7.1 數據稀疏與內存占用問題一旦把用戶物品矩陣構建成numpy二維數組內存就會按n*m增長。MovieLens 1M版本有6000用戶和4000電影float64數組大約是192MB這還能接受但如果換成真實業務里的千萬用戶和百萬物品二維稠密矩陣直接爆內存。我在這類項目里常用的優化手段有三種改用scipy.sparse.csr_matrix存儲只記錄有評分的位置。用戶CF轉物品CFm通常遠小于n。相似度矩陣分批計算、落地緩存不要重復構建。這里還要提一個常見的隱蔽錯誤numpy矩陣默認用float64如果評分數據本身是整數用np.float32就夠內存直接砍半。很多人忽略這個細節。7.2 冷啟動問題的三種應對方式冷啟動分兩類新用戶沒有歷史行為新電影沒有評分。這個項目里我做的比較全面的應對是新用戶冷啟動放一個“熱門榜”兜底也就是全站評分數量最多、評分均值最高的電影組合。新電影冷啟動用基于內容推薦把新電影的類型特征和已有電影算相似度找合適的召回位。混合通道用戶評分數量少于5條時直接走熱門榜加內容推薦達到閾值后切換協同過濾。很多人問這個閾值怎么定我的經驗是小于等于5條時用戶觀影偏好分布根本不穩強行上協同過濾預測出來的分數可能非常離譜直接從熱門榜里選是性價比最高的方案。7.3 結果不合預期的排查思路我踩過最典型的坑是推薦列表里出現大量用戶已經看過的電影。這種情況多半是遍歷預測時把訓練集里的已評分電影也納入了候選集導致預測分數非常接近真實高分沖進Top-N里。排查時只要在生成候選集時加一句unrated_mask user_item[user_idx] 0 candidates np.where(unrated_mask)[0]另一個坑是時間穿越。如果訓練集和測試集劃分時沒有按時間排序而是隨便隨機切那么模型會“看到未來”的評分測試集上的RMSE虛低。這個現象在數據量小時尤其明顯我的一次實驗里隨機劃分RMSE是0.86按時間劃分直接變成了0.96。別把0.86當做好結果那是模型作弊了。還有一個經常被問的問題“為什么推薦出來的電影評分均值才3.5”因為均值回歸效應。評分極端分布的數據被中心化處理后預測值自然向平均值收縮這不是bug。如果你希望推薦列表看起來更有“驚喜感”可以在預測分數后加一個很小的權重偏置或者改用排序學習的方式去優化用戶交互點擊率而不是純回歸分數。8. 項目擴展思路與個人實操心得做完這個項目以后我最大的體會是推薦系統60%的精力要花在數據和評測上真正的模型部分反而是最“標準化”的。很多人一上來就折騰深度學習模型、圖神經網絡但其實協同過濾、邏輯回歸這波經典方法配合好的特征工程和評估體系已經能解決大量業務問題。如果你想在現有代碼基礎上繼續擴展我建議按這個優先級來加入相似度矩陣的持久化緩存用npy格式存盤避免每次重算。把訓練好的模型封裝成函數再接一個簡單的Flask API做成一個能實時響應的推薦服務。加入ALS矩陣分解作為第四個算法對比一下它在稀疏數據上的表現是否優于協同過濾。在評估環節加入PrecisionN和RecallN直接衡量推薦列表中命中用戶真實喜歡電影的比例。最后分享一個小技巧調參時不要每次只改一個參數看半天結果直接把參數組合做成笛卡爾積跑網格搜索用RMSE熱力圖看整體趨勢。這樣既能發現參數之間的相互作用又能快速定位最優區間。整套代碼跑通之后你可以試著把數據集換成自己的業務數據——哪怕是圖書、音樂、商品評分算法骨架都不用大改改數據加載邏輯就行。這個遷移能力才是做這些實驗真正值錢的地方。本文還有配套的精品資源點擊獲取