由於最近在用C#寫一個項目,項目的關鍵部分就是需要用到推薦算法,以前基本沒有去了解過推薦算法,看了很多資料,瞭解到目前基於物品的協同過濾推薦算法是最符合我的項目的。所以就動手從數據庫的連接取原始數據到最終的推薦實現了這個算法,在具體實現過程中碰到了很多坎坷,在此記錄下我用C#實現這個算法整個過程的思路和具體實現代碼,其他語言大致相同,將代稍加改動就可。
推薦算法相關介紹(來源於網絡資料)
1.基於用戶的協同過濾算法(UserCF)
該算法利用用戶之間的相似性來推薦用戶感興趣的信息,個人通過合作的機制給予信息相當程度的迴應(如評分)並記錄下來以達到過濾的目的進而幫助別人篩選信息,迴應不一定侷限於特別感興趣的,特別不感興趣信息的紀錄也相當重要。但有很難解決的兩個問題,一個是稀疏性,即在系統使用初期由於系統資源還未獲得足夠多的評價,很難利用這些評價來發現相似的用戶。另一個是可擴展性,隨着系統用戶和資源的增多,系統的性能會越來越差。
2.基於物品的協同過濾算法(ItemCF)
內容過濾根據信息資源與用戶興趣的相似性來推薦商品,通過計算用戶興趣模型和商品特徵向量之間的向量相似性,主動將相似度高的商品發送給該模型的客戶。由於每個客戶都獨立操作,擁有獨立的特徵向量,不需要考慮別的用戶的興趣,不存在評價級別多少的問題,能推薦新的項目或者是冷門的項目。這些優點使得基於內容過濾的推薦系統不受冷啓動和稀疏問題的影響。
基於物品的推薦算法流程
1.構建用戶–>物品的倒排;
2.構建物品與物品的同現矩陣;
3.計算物品之間的相似度,即計算相似矩陣;
4.根據用戶的記錄,給用戶推薦物品;
具體實現步驟:
基礎數據實例,這個數據是從測試數據庫拿出來的數據,用戶列爲用戶ID,物品列是物品ID,興趣度暫時都設爲1:
1.構建用戶–>物品的倒排矩陣:
用戶->物品倒排矩陣主要是用來標識用戶對某個物品是否有相應的記錄(例如喜歡或購買過)
根據基礎數據構建的用戶–>物品的倒排矩陣矩陣應如下:
解釋:行表示用戶,列表示物品 ,1則表示有此記錄,如座標[0,4]表示第一行的第五列,也就是ID爲2的用戶購買或喜歡過ID爲57的物品。
所以現在我們需要把基礎數據構建成這麼一個用戶–>物品的倒排矩陣。
就是構建這個矩陣,我開始一直沒想通到底該怎麼去把原始數據構建成這個矩陣。其實我們一步一步來分析。
- 構建思路
- 需要用戶ID列表(並且是按照升序或者降序進行排序,這樣可以方便查找以及獲取)
- 需要物品ID列表(升序或降序排序)
- 用戶ID所對應的物品ID記錄
- 創建一個倒排矩陣的二維數組,因爲倒排矩陣行表示user,列表示good,因此這個二維數組的就是int[usersCount,goodsCount]
- 循環得到從數據庫中取得的數據的每一行中的user值和good值,說明用戶user購買過此good
- 在經過升序排序的List<int> users 和List<int> goods中查找user和good值的索引a,b
- 將二維數組的[a,b]值置爲1,表示對應的user是否購買過或喜歡過此good,循環完畢後即可得到用戶->物品的倒排矩陣二維數組
實際代碼如下,
//連接數據庫操作和計算代碼片段運行時間
GlobalFunAndVar global = new GlobalFunAndVar();
//物品列表(ID)
List<int> users;
//用戶列表(ID)
List<int> goods;
//物品的總用戶數
int[] goodUsersNum;
//存放數據庫取得的數據
DataTable dataTable;
//用戶的總數量
int usersCount = 0;
//物品的總數量
int goodsCount = 0;
/// <summary>
/// 轉化數據庫中的數據
/// </summary>
public void init()
{
//統計代碼片段運行時間開始
global.stopwatchBegin();
dataTable = global.SqlDataAdapDS("select [user],good from recommendTest group by [user],good").Tables[0];
//統計代碼片段運行時間結束
global.stopwatchEnd();
int dataTableRowCount = dataTable.Rows.Count;
SortedSet<int> setUser = new SortedSet<int>();
SortedSet<int> setGood = new SortedSet<int>();
for (int i = 0; i < dataTableRowCount; i++)
{
setUser.Add((int)dataTable.Rows[i][0]);
setGood.Add((int)dataTable.Rows[i][1]);
}
users = setUser.ToList();
goods = setGood.ToList();
usersCount = users.Count;
goodsCount = goods.Count;
}
//用戶->物品的倒排矩陣二維數組
int[,] users_Goods_Matrix;
/// <summary>
/// 用戶->物品的倒排矩陣二維數組
/// </summary>
/// <returns></returns>
public int[,] Get_User_Goods_Matrix()
{
init();
global.stopwatchBegin();
int dataTableRowCount = dataTable.Rows.Count;
users_Goods_Matrix = new int[usersCount, goodsCount];
//循環原始數據的每一行
foreach (DataRow row in dataTable.Rows)
{
//得到原始數據的第i行的用戶值和物品值在users中的索引
int a = users.IndexOf((int)row[0]);
int b = goods.IndexOf((int)row[1]);
//將倒排矩陣中的[a,b]值置爲1
users_Goods_Matrix[a, b] = 1;
}
global.stopwatchEnd();
Console.WriteLine("用戶->物品的倒排矩陣(www.b0c0.com)");
OutPutArray<int>(users_Goods_Matrix);
return users_Goods_Matrix;
}
運行結果:
哈,現在已經正確構建出用戶->物品的倒排矩陣二維數組了,接下來就是去構建物品與物品的同現矩陣二維數組。
2.構建物品與物品的同現矩陣:
同現矩陣是物品-物品的矩陣,表示同時購買過或喜歡過矩陣點[x,y](x,y分別表示一個物品)對應的兩個物品的用戶數,是根據用戶物品倒排表計算出來的。
如根據上面的用戶物品倒排矩陣可以計算出如下的共現矩陣:
構建思路:
- 創建一個物品與物品同現矩陣的二維數組,行和列都表示good,所以此矩陣是一個方陣,因此這個二維數組就是int[goodsCount,goodsCount]
- 循環倒排矩陣的二維數組每一行
- 循環倒排矩陣的二維數組每一行中的每一列
- 比如現在開始循環,從[0,0]座標開始,則就把[0,0]的值與[0,1]、[0,2]、[0,3]、[0,4]、[0,5]的值進行逐個判斷。
- 如果兩個的值都爲1,則說明此用戶購買過或喜歡過這兩個good的記錄、現在就該將同現矩陣中的對應座標值加1
- 那麼這個對應座標[x,y]是多少呢,x應該是此時循環倒排矩陣中列的值,y應該是對比座標的y。
- 比如現在循環到[0,2],[0,2]與[0,4]的值都爲1,則同現矩陣的對應座標[2,4]值就加1。
- 同現矩陣的對角線值是必定相等的,所以同現矩陣的對應座標[4,2]值也應該加1(但是在實際使用中沒有什麼意義,只用到對角線一邊的值,而且經我測試20萬數據量,只賦值對角線一邊,我經過了多次測試,測試結果就是代碼效率將提高三分之一)
實際代碼如下:
//物品與物品的同現矩陣二維數組
int[,] Cooccurrence_Matrix;
//物品的總用戶數
int[] goodUsersNum;
//用戶購買過或喜歡過對應的物品 key爲users中的下標,value所有物品字符串,中間用,隔開
Dictionary<int, string> likeGoods = new Dictionary<int, string>();
//用戶沒有購買過或喜歡過對應的物品 key爲users中的下標,value所有物品字符串,中間用,隔開
Dictionary<int, string> recommendGoods = new Dictionary<int, string>();
public int[,] Get_Cooccurrence_Matrix1()
{
int i, j, k, y, CompareCount;
//物品與物品的同現矩陣二維數組
Get_User_Goods_Matrix();
global.stopwatchBegin();
//同現矩陣
Cooccurrence_Matrix = new int[goodsCount, goodsCount];
//存儲物品的總用戶數
goodUsersNum = new int[goodsCount];
for (i = 0; i < usersCount; i++)
{
string userLikeGoodsStr = "";
string recommendGoodsStr = "";
for (j = 0; j < goodsCount; j++)
{
/* 判斷起始對比值是否爲1,
* 如果爲0的話說明user_Goods_Matrix[i, j]的值與第i行中的任何一個數據一定是對比不成功的則直接跳過。
*/
if (users_Goods_Matrix[i, j] == 1)
{
userLikeGoodsStr = userLikeGoodsStr + j + ",";
goodUsersNum[j] = goodUsersNum[j] + 1;
//統計物品總用戶數結束
//實際對比次數=CompareCount-1
CompareCount = goodsCount - j;
for (k = 1; k < CompareCount; k++)
{
y = j + k;
if (users_Goods_Matrix[i, y] == 1)
{
Cooccurrence_Matrix[j, y]++;
//Cooccurrence_Matrix[y, j]++; 放棄對角線值
}
}
}
else
recommendGoodsStr = recommendGoodsStr + j + ",";
}
likeGoods.Add(i, userLikeGoodsStr);
recommendGoods.Add(i, recommendGoodsStr);
}
global.stopwatchEnd();
Console.WriteLine("物品與物品的同現矩陣(www.b0c0.com):");
OutPutArray<int>(Cooccurrence_Matrix);
return Cooccurrence_Matrix;
}
運行結果:
構建出物品與物品的同現矩陣後,接下來就是去構建物品之間的餘弦相似矩陣了。
3.構建物品與物品的餘弦相似矩陣:
計算物品之間的相似度,即計算相似矩陣,兩個物品之間的相似度公式如下圖所示。
其中分子含義爲:N(i)表示喜歡物品i的用戶數,N(j)表示喜歡物品j的用戶數,|N(i)⋂N(j)|表示同時喜歡物品i,j的用戶數。
所以物品與物品的同現矩陣其實就是此式的分子。
分母含義爲:N(i)表示喜歡物品i的用戶數*N(j)表示喜歡物品j的用戶數,然後開平方。
物品的總用戶數已經在構建出物品與物品的同現矩陣方法中求出.
所以構建物品之間的餘弦相似矩陣二維數組只需要循環物品與物品的同現矩陣對角線一邊的每一個值然後再套公式即可。
實際代碼如下:
//物品之間的餘弦相似矩陣二維數組
double[,] Cosine_Similar_Matrix;
/// <summary>
/// 構建物品之間的餘弦相似矩陣二維數組
/// </summary>
/// <returns></returns>
public double[,] Get_Cosine_Similar_Matrix()
{
Get_Cooccurrence_Matrix1();
Cosine_Similar_Matrix = new double[goodsCount, goodsCount];
int i=0, j=0;
for (i = 0; i < goodsCount; i++)
{
if(i<=j)
for (j = 0; j < goodsCount; j++)
{
if (Cooccurrence_Matrix[i, j] != 0)
{
double a = Math.Round((Cooccurrence_Matrix[i, j] / Math.Sqrt(goodUsersNum[i] * goodUsersNum[j])), 2);
Cosine_Similar_Matrix[i, j] = a;
}
}
}
Console.WriteLine("物品之間的餘弦相似矩陣:");
OutPutArray<double>(Cosine_Similar_Matrix);
return Cosine_Similar_Matrix;
}
運行結果:
下一步就是計算預測興趣度,也就是推薦物品了。
4.計算預測興趣度(推薦物品):
最終推薦的是什麼物品,是由預測興趣度決定的。
公式:物品j預測興趣度=用戶喜歡的物品i的興趣度×物品i和物品j的相似度
這個用戶喜歡的物品i的興趣度我在這裏只是測試 都默認爲1,實際上這個興趣度可以是對該物品的評分、或者是對該物品的購買次數,或者訪問次數等實際情況來決定的。在我的項目裏可以是對該物品的購買次數和評分混合選項綜合來作爲興趣度。
所以計算預測興趣度需要:
//用戶購買過或喜歡過對應的物品
Dictionary<int, string> likeGoods = new Dictionary<int, string>();
//用戶沒有購買過或喜歡過對應的物品
Dictionary<int, string> recommendGoods = new Dictionary<int, string>();
比如:
比如2用戶購買過15、39、57三個物品,興趣度分別爲1、1、1
2用戶的預測物品(24、41、60)的興趣度就爲:
24物品:1×0.41+1×0.5=0.91
41物品:1×0.58=0.58
60物品:1×0.33+1×0.67+1×0.41=1.41
其實就是可以把購買過的物品(15、39、57)看成餘弦相似矩陣二維數組x座標,預測物品(24、41、60)看成餘弦相似矩陣二維數組y座標。以此來求出所有物品的預測相似度。
實際代碼如下:
/// <summary>
/// 計算預測興趣度
/// </summary>
/// <returns></returns>
public void Get_Similarity()
{
Get_Cosine_Similar_Matrix();
global.stopwatchBegin();
string likeGoodsStr = "";
string recommendGoodsStr = "";
for(int b=0;b<usersCount;b++)
{
//存儲爲用戶推薦的物品集合,key爲推薦物品Id,value推薦度
Dictionary<int, double> tes = new Dictionary<int, double>();
likeGoods.TryGetValue(b, out likeGoodsStr);
recommendGoods.TryGetValue(b, out recommendGoodsStr);
if (!string.IsNullOrEmpty(recommendGoodsStr))
{
int[] m = Array.ConvertAll(recommendGoodsStr.Substring(0, recommendGoodsStr.Length - 1).Split(','), int.Parse);
if (!string.IsNullOrEmpty(likeGoodsStr))
{
int[] n = Array.ConvertAll(likeGoodsStr.Substring(0, likeGoodsStr.Length - 1).Split(','), int.Parse);
for (int i = 0; i < m.Count(); i++)
{
int x = m[i];
double goodSimilarity = 0.00;
for (int j = 0; j < n.Count(); j++)
{
int y = n[j];
if (x < y)
goodSimilarity += Cosine_Similar_Matrix[x, y];
else
goodSimilarity += Cosine_Similar_Matrix[y, x];
}
tes.Add(m[i], goodSimilarity);
}
}
}
tes = tes.OrderByDescending(p => p.Value).ToDictionary(o => o.Key, p => p.Value);
string va = "";
Console.WriteLine("爲用戶【"+users[b]+"】推薦:");
foreach (KeyValuePair<int, double> k in tes)
{
Console.WriteLine("物品:" + goods[k.Key] + "推薦度:" + k.Value);
top10++;
}
}
global.stopwatchEnd();
}
運行結果:
至此整個基於物品的協同過濾推薦算法就結束了。注意我的算法實現是把所有的用戶推薦都計算了,在實際應用中,我們只計算指定用戶的推薦就行。
可以對代碼改動爲這樣:
public int[,] Get_Cooccurrence_Matrix(int userSub)
{
int i, j, k, y, CompareCount;
//物品與物品的同現矩陣二維數組
Get_User_Goods_Matrix();
global.stopwatchBegin();
//同現矩陣
Cooccurrence_Matrix = new int[goodsCount, goodsCount];
//存儲物品的總用戶數
goodUsersNum = new int[goodsCount];
for (i = 0; i < usersCount; i++)
{
string userLikeGoodsStr = "";
string recommendGoodsStr = "";
for (j = 0; j < goodsCount; j++)
{
/* 判斷起始對比值是否爲1,
* 如果爲0的話說明user_Goods_Matrix[i, j]的值與第i行中的任何一個數據一定是對比不成功的則直接跳過。
*/
if (users_Goods_Matrix[i, j] == 1)
{
if (userSub == i)
userLikeGoodsStr = userLikeGoodsStr + j + ",";
goodUsersNum[j] = goodUsersNum[j] + 1;
//統計物品總用戶數結束
//實際對比次數=CompareCount-1
CompareCount = goodsCount - j;
for (k = 1; k < CompareCount; k++)
{
y = j + k;
if (users_Goods_Matrix[i, y] == 1)
{
Cooccurrence_Matrix[j, y]++;
//Cooccurrence_Matrix[y, j]++; 放棄對角線值
}
}
}
else
if (userSub == i)
recommendGoodsStr = recommendGoodsStr + j + ",";
}
if (userSub == i)
{
likeGoods.Add(i, userLikeGoodsStr);
recommendGoods.Add(i, recommendGoodsStr);
}
}
global.stopwatchEnd();
Console.WriteLine("物品與物品的同現矩陣(www.b0c0.com):");
OutPutArray<int>(Cooccurrence_Matrix);
return Cooccurrence_Matrix;
}
public double[,] Get_Cosine_Similar_Matrix(int userSub)
{
Get_Cooccurrence_Matrix(userSub);
Cosine_Similar_Matrix = new double[goodsCount, goodsCount];
int i = 0, j = 0;
for (i = 0; i < goodsCount; i++)
{
if (i <= j)
for (j = 0; j < goodsCount; j++)
{
if (Cooccurrence_Matrix[i, j] != 0)
{
double a = Math.Round((Cooccurrence_Matrix[i, j] / Math.Sqrt(goodUsersNum[i] * goodUsersNum[j])), 2);
Cosine_Similar_Matrix[i, j] = a;
}
}
}
Console.WriteLine("物品之間的餘弦相似矩陣(www.b0c0.com):");
OutPutArray<double>(Cosine_Similar_Matrix);
return Cosine_Similar_Matrix;
}
public void Get_Similarity(int user)
{
Get_Cosine_Similar_Matrix(users.IndexOf(user));
global.stopwatchBegin();
string likeGoodsStr = "";
string recommendGoodsStr = "";
//存儲爲用戶推薦的物品集合,key爲推薦物品Id,value推薦度
Dictionary<int, double> tes = new Dictionary<int, double>();
likeGoods.TryGetValue(users.IndexOf(user), out likeGoodsStr);
recommendGoods.TryGetValue(users.IndexOf(user), out recommendGoodsStr);
if (!string.IsNullOrEmpty(recommendGoodsStr))
{
int[] m = Array.ConvertAll(recommendGoodsStr.Substring(0, recommendGoodsStr.Length - 1).Split(','), int.Parse);
if (!string.IsNullOrEmpty(likeGoodsStr))
{
int[] n = Array.ConvertAll(likeGoodsStr.Substring(0, likeGoodsStr.Length - 1).Split(','), int.Parse);
for (int i = 0; i < m.Count(); i++)
{
int x = m[i];
double goodSimilarity = 0.00;
for (int j = 0; j < n.Count(); j++)
{
int y = n[j];
if (x < y)
goodSimilarity += Cosine_Similar_Matrix[x, y];
else
goodSimilarity += Cosine_Similar_Matrix[y, x];
}
tes.Add(m[i], goodSimilarity);
}
}
}
tes = tes.OrderByDescending(p => p.Value).ToDictionary(o => o.Key, p => p.Value);
string va = "";
int top10 = 0;
Console.WriteLine("爲用戶【" + user + "】推薦:");
foreach (KeyValuePair<int, double> k in tes)
{
if (top10 != 10)
{
//goodsName.TryGetValue(goods[k.Key], out va);
Console.WriteLine("物品:" + goods[k.Key] + "推薦度:" + k.Value);
top10++;
}
else
break;
}
global.stopwatchEnd();
}
調用Get_Similarity的時候只需傳入用戶的ID即可,就能得到此用戶的Top10推薦物品。
下面我在我筆記本上測試了一下效率
這個算法效率基本上影響較大的因素就是物品的數據量,而物品在我的項目裏預測基本在1000左右。
1、往數據庫隨生成成1萬條購買記錄(數據可重複,代表多次購買),用戶Id範圍在1-1000之內,物品Id範圍在1-1000之內,基本可理解爲1千用戶量、1千物品量。
我在init方法中放了兩個測試時間,因爲init中有連接數據庫的操作,所以我測試了連接數據庫的時間和其他片段的時間。
在這個數據量下性能還是可以的,可以看到連接數據庫操作其實就佔了一半的時間,總運行時間大概在320毫秒,去除數據庫的操作以及輸出,程序運行實際時間大概在150毫秒。
2、往數據庫隨生成成10萬條購買記錄(數據可重複,代表多次購買),用戶Id範圍在1-1000之內,物品Id範圍在1-1000之內,基本可理解爲1千用戶量、1千物品量。
經過多次運行測試、在這個數據量下總運行時間大概在1100±100毫秒,去除數據庫的操作以及輸出, 程序運行實際時間大概在650±100毫秒。
3、往數據庫隨生成成50萬條購買記錄(數據可重複,代表多次購買),1萬用戶量、1千物品量。
經過多次運行測試、在這個數據量下總運行時間大概在5200±200毫秒,去除數據庫的操作以及輸出, 程序運行實際時間大概在3700±100毫秒。
4、往數據庫隨生成成20萬條購買記錄(數據可重複,代表多次購買),1萬用戶量、1萬物品量。
在這個數據量下可以看到在物品量增加的情況下,效率很慢。
所以我寫的這個算法實現還需要進一步優化。
更新:
由於這個是c#的,不過其實不管什麼實現語言,只要知道思路就行,推薦大家去看一下java版的apache開源的推薦系統:mahout。但是還是推薦大家去看一下。不過博主也還一直沒看的。
近期有很多人聯繫我說要源碼,其實源碼在這個裏面已經都寫了。少量的一些不重要的我就沒放着上面。關於GlobalFunAndVar這個類是我自定義的一個全局類,在這裏面就只是用到統計代碼運行時間和連接數據庫執行sql語句。OutPutArray這個方法是自定義的一個輸出二維數組的方法。代碼如下:
/// <summary>
/// 輸出指定的二維數組
/// </summary>
/// <param name="array"></param>
public void OutPutArray<T>(T[,] array)
{
bool isDouble = false;
if (array[0, 0] is double)
isDouble = true;
int len1 = array.GetLength(0);
int len2 = array.GetLength(1);
for (int i = 0; i < len1; i++)
{
for (int j = 0; j < len2; j++)
{
//if (j >= i)
switch (isDouble)
{
case false:
Console.Write(array[i, j] + " ");
break;
case true:
if (array[i, j].ToString().Length == 1)
Console.Write(array[i, j] + ".00 ");
else
Console.Write(array[i, j] + " ");
break;
}
/*
else
Console.Write(array[i, j] + " ");
*/
}
Console.WriteLine();
}
}
191221更新(重大更新): 修復執行錯誤,並給出一個Demo使用樣例代碼,大家請重新重新下載dll,並可以參考樣例代碼來進行測試使用。
191108更新:由於我看有人需要,我把這個最終的推薦算法以及一些輔助方法封裝成了一個dll,大家只需引入這個dll就可以。
xml文件爲註釋,因爲只引入dll的話,在vs中調用dll中的方法是沒有註釋說明的,只需把這個註釋xml和dll放在同一目錄(dll和xml文件名必須相同!),vs直接引入dll就能顯示註釋。
方法說明:
一共有兩個類接口調用:GlobalFunAndVar(輔助通用類)、RecommendBaseGood(基於物品的推薦接口)
RecommendBaseGood:
/// <summary>
/// 構造方法(數據初始化)
/// </summary>
/// <param name="dataTable">
/// 第1列爲userId 用戶id,
/// 第2列爲goodId 物品id,
/// 第3列爲rating 評分,
/// </param>
/// <param name="isDeBug">是否打開調試(只用於輸出構建的矩陣結果以及統計運行時間)</param>
public RecommendBaseGood(DataTable dataTable, bool isDeBug);
/// <summary>
/// 爲某個具體用戶計算預測興趣度
/// </summary>
/// <param name="user">用戶id</param>
/// <returns>key:物品id,value:物品推薦度</returns>
public Dictionary<int, double> Get_SimilarityByUser(int user);
/// <summary>
/// 爲所有用戶計算預測興趣度
/// </summary>
/// <returns>key:用戶id,value:(key:物品id,推薦度) </returns>
public Dictionary<int, Dictionary<int, double>> Get_SimilarityAllUser()
GlobalFunAndVar:
/// <summary>
/// 獲得指定的MethodBase對象
/// </summary>
/// <param name="i">指定是屬於誰的的方法</param>
/// <returns></returns>
public MethodBase getMethodBase(int i)
/// <summary>
/// 監視代碼運行時間開始
/// </summary>
public void stopwatchBegin()
/// <summary>
/// 監視代碼運行時間結束
/// </summary>
public void stopwatchEnd()
/// <summary>
/// 指定的sql查詢並填充到DataSet返回
/// </summary>
/// <param name="sql">sql語句</param>
/// <param name="connection">SqlConnection</param>
/// <returns></returns>
public DataSet SqlDataAdapDS(string sql, SqlConnection connection)