博客推荐系统
目的:用户进入博客以后,会在底部推荐相似文章
技术:
| 目的 | 技术手段 |
|---|---|
| html转文本 | bs4.BeautifulSoup(html, “html.parser”).get_text() |
| 中文分词 | jieba.cut(text, use_paddle=True) |
| 构建词频表 | pandas |
| SVD、稀疏矩阵 | numpy.sparse, numpy.sparse.linalg.eigs |
实现
demo的截图:




上述代码输出了推荐的文章名称,相似度,奇异矩阵和原矩阵的几何距离
在Django中的demo
首先要导入django环境

其次是测试一下上面的API,首先是分词


我找了两篇文章,然后试了试,发现性能不够乐观,主要消耗的时间在jieba的中文分词上,占了88%
然后是构造奇异矩阵


由上可以看出,矩阵的SVD分解用的时间也非常长,究其原因是因为有一个13064大小的方阵进行了特征值分解。这里我可以优化,看后续
第一种,转为稀疏矩阵以后eig操作耗时比较

第二种,先相乘,再计算eig

看起来目前矩阵相乘耗时不是大事,主要在eig上,下结果为:

以前是17秒,现在是0.8秒,变化可观。但这里有个雷:我默认计算前10个特征值,如果将来前10个特征值难以描述全部信息该怎么办呢?一般来说,只要我写10篇文章,那特征值分布就在10上了,如果写100篇那估计就到100了。这里的解决方案就是计算从大到小排列的特征值的梯度,因为特征值大小是一个指数下降的曲线,所以如果梯度趋于0,就代表已经包含了矩阵的全部能量了。所以就可以以特征值当前梯度大小为依据来统计应该计算前多少个特征值。
jieba分词优化
pip install jieba_fast
使用这个库替换jieba,时间如下

以前是平均60秒左右,现在1.27秒,快了近60倍,推荐数据构建总用时2秒,非常完美
推荐精度
我直接对比文章的投影,取前几个最长的数据。
发现有那么几个离群词影响非常大,于是进行词频矩阵标准化,完成以后得到了比较好的推荐结果。以下是文章列表。

这里我随便抄了点网上的文章,根据已有的文章推荐得到了以下结果:

以下是demo代码
import time
import jieba
import re
import os
import pdb
import django
import json
import pandas as pd
import numpy as np
import jieba_fast
from numpy import linalg as la
from scipy.sparse import linalg as spla
from matplotlib import pyplot as plt
from scipy import sparse as sp
from bs4 import BeautifulSoup
class Recommend:
RECOMMEND_DATA_ROOT = r'I:\proj-django\myweb\media'
def __init__(self):
# 读取分词数据
self.sentences = pd.read_pickle(
os.path.join(self.RECOMMEND_DATA_ROOT, 'sentences.pickle'))
self.freq = self.generate_words_freq() # 获取词频
self.freq_compress = self.compress_freq(sp.csr_matrix(self.freq.values))
# 统计总词数和不重复的词数
self.words_count = 0
self.total_words = 0
for words in self.sentences['words']:
self.words_count += words.index.size
self.total_words += words.sum()
def generate_words_freq(self):
freq = pd.DataFrame(list(self.sentences['words']), index=self.sentences['id'])
freq.fillna(0, inplace=True)
# 标准化矩阵
A = freq.values
# 按词标准化
A = (A - A.mean(axis=0)) / A.std(axis=0)
# 按文章标准化
A = (A - A.mean(axis=1).reshape(A.shape[0], 1)) / A.std(axis=1).reshape(A.shape[0], 1)
return pd.DataFrame(A, columns=freq.columns, index=freq.index)
@classmethod
def compress_freq(cls, freq):
U, sigma, VT = spla.svds(freq, k=min(freq.shape) - 1, which='LM')
return U, sigma, VT
def __repr__(self):
ret = '总词数:' + str(self.total_words)
ret += '\n不重复词数:' + str(self.words_count)
return ret
def __call__(self, words, N=5, method='shadow'):
# 生成词向量
vec = self._to_words_vec(words)
if method == 'shadow': # 计算content在当前词频阵上的投影
A = self.freq.values
proj = A.dot(vec) / la.norm(vec)
proj_sort = proj.argsort()[::-1]
elif method == 'svd': # 计算content在SVD矩阵上的投影
U, sigma, VT = self.freq_compress
proj = np.mat(U) * np.diag(sigma) * VT * np.mat(vec).T
proj = proj.A.T[0]
proj_sort = proj.argsort()[::-1]
else:
raise ValueError('method=' + str(method) + ' is not support')
return self.freq.index.values[proj_sort[1:(N+1)]]
def _to_words_vec(self, words):
vec = pd.Series(np.zeros(self.freq.shape[1]), index=self.freq.columns, name='vec')
vec[words.index] = words.values
return vec.values
def get_title(self, posts_id):
return [self.sentences.loc[self.sentences['id'] == pid, 'title'].item()
for pid in posts_id]
if __name__ == '__main__':
rec = Recommend()
# 获取测试数据
pid, ptitle, pdata = rec.sentences.iloc[12]
print('测试数据:', ptitle, '\n')
# 生成词频矩阵
proj = rec(pdata, N=5, method='shadow')
title = rec.get_title(proj)
print('使用shadow推荐结果:\t', title)
proj = rec(pdata, N=5, method='svd')
title = rec.get_title(proj)
print('\n使用svd推荐结果:\t', title)
py

测试结果:

总体效果感觉还行。下一步,部署到我的博客上。
这里总结一个经验:一般理论分析和实践是不一样的,在这里推荐算法就是理论分析,部署到博客上就是实践。必须把两者分开来做,否则会遇到很多问题,原因就是理论和实践面对的问题不一样,理论面对的是数学逻辑和精确度,而实践则需要代入很多现实因素(比如开发环境、性能等等)。
部署
这里简介一下我的博客系统。使用django。
其中有app,叫做post。
有个model,叫做Post,其中保存的就是所有文章的数据。
Post包含title, id, content等字段,这里只用到了这几个。
定时更新推荐数据没有完成,准备等部署到linux服务器上的时候再用django-crontab实现。
下面是post app下的recommend.py文件内容
import time
import jieba
import re
import os
import django
import json
import pandas as pd
import numpy as np
import jieba_fast
from numpy import linalg as la
from scipy.sparse import linalg as spla
from scipy import sparse as sp
from bs4 import BeautifulSoup
from django.conf import settings
from .models import Post
"""
a. 读取所有文章,生成dict(title=xxx, content=xxx, created_time=xxx)的列表
b. 生成所有文章及其分词对应的词频,pd.DataFrame(index=词语, columns=文章ID, data=词频)
c. 压缩词频表,进行SVD分解,并转为稀疏矩阵
d. 保存结果至文件
e. 开启定时任务,每天执行一次上述操作
f. 加载已知结果,对当前文章进行相似度计算,得出相似度结果,并推荐文章
缺点:
未考虑大数据情况
未考虑时CPU算力不够(或许可以把SVD的稀疏矩阵发送至浏览器,由用户电脑来计算推荐结果)
"""
def split_word(content, min_length=2):
"""
将文章进行分词,返回包含words的字典
min_length: 词的最短长度
"""
words = re.sub(r'[,,.。!!??::()#]', ' ', content)
# seg_list = jieba.cut(words, use_paddle=True)
seg_list = jieba_fast.cut(words)
words = []
for word in seg_list:
words.extend(word.split())
words = list(filter(lambda x: len(x) > min_length, words))
return words
def read_posts():
"""
从Post模型中读取所有需要的数据,并进行一定预处理,最后返回处理的结果
"""
queryset = Post.get_latest_posts()
ret = pd.DataFrame(columns=['id', 'title', 'words'])
for item in queryset:
post = {
'id': item.id,
'title': item.title,
'words': pd.Series(split_word(item.content), name=item.id).value_counts(),
}
ret = ret.append(post, ignore_index=True)
return ret
class Recommend:
RECOMMEND_DATA_ROOT = settings.RECOMMEND_DATA_ROOT
def __init__(self, datafrom='calc'):
"""
datafrom : 原始数据从哪里来,默认是计算得到,可选[calc, file]
"""
# 读取分词数据
if datafrom == 'file':
self.sentences = pd.read_pickle(os.path.join(self.RECOMMEND_DATA_ROOT, 'sentences.pickle'))
self.freq = pd.read_pickle(os.path.join(self.RECOMMEND_DATA_ROOT, 'words_freq.pickle'))
tmp = np.load(os.path.join(self.RECOMMEND_DATA_ROOT, 'words_freq_compress.npy'), allow_pickle=True)[0]
self.freq_compress = tmp['U'], tmp['sigma'], tmp['VT']
pass
elif datafrom == 'calc':
self.sentences = read_posts()
self.freq = self.generate_words_freq() # 获取词频
self.freq_compress = self.compress_freq(sp.csr_matrix(self.freq.values))
self._save() # 更新计算所得结果
# 统计总词数和不重复的词数
self.words_count = 0
self.total_words = 0
for words in self.sentences['words']:
self.words_count += words.index.size
self.total_words += words.sum()
def generate_words_freq(self):
"""生成标准化后的词频矩阵, type=pd.DataFrame"""
freq = pd.DataFrame(list(self.sentences['words']), index=self.sentences['id'])
freq.fillna(0, inplace=True)
# 标准化矩阵
A = freq.values
# 按词标准化
A = (A - A.mean(axis=0)) / A.std(axis=0)
# 按文章标准化
A = (A - A.mean(axis=1).reshape(A.shape[0], 1)) / A.std(axis=1).reshape(A.shape[0], 1)
return pd.DataFrame(A, columns=freq.columns, index=freq.index)
@classmethod
def compress_freq(cls, freq):
"""使用稀疏矩阵奇异值分解压缩矩阵"""
U, sigma, VT = spla.svds(freq, k=min(freq.shape) - 1, which='LM')
return U, sigma, VT
def __repr__(self):
ret = '总词数:' + str(self.total_words)
ret += '\n不重复词数:' + str(self.words_count)
return ret
def __call__(self, words, N=5, method='svd'):
"""计算type=pd.Series的words-counts数据的相似文章
N: 返回前多少相似的文章
method: [shadow, svd], 使用哪种方法进行判断,svd计算性能更好
"""
# 生成词向量
vec = self._to_words_vec(words)
if method == 'shadow': # 计算content在当前词频阵上的投影
A = self.freq.values
proj = A.dot(vec) / la.norm(vec)
proj_sort = proj.argsort()[::-1]
elif method == 'svd': # 计算content在SVD矩阵上的投影
U, sigma, VT = self.freq_compress
proj = np.mat(U) * np.diag(sigma) * VT * np.mat(vec).T
proj = proj.A.T[0]
proj_sort = proj.argsort()[::-1]
else:
raise ValueError('method=' + str(method) + ' is not support')
return self.freq.index.values[proj_sort[:N]]
def _to_words_vec(self, words):
"""type=pd.Series的words-counts的数据转为符合当前词库的词向量"""
vec = pd.Series(np.zeros(self.freq.shape[1]), index=self.freq.columns, name='vec')
vec[words.index] = words.values
vec = (vec - vec.mean()) / vec.std()
return vec.values
def _save(self):
"""保存计算所需的所有数据,以便重新加载"""
# 保存词典
self.sentences.to_pickle(os.path.join(self.RECOMMEND_DATA_ROOT, 'sentences.pickle'))
# 保存词频
self.freq.to_pickle(os.path.join(self.RECOMMEND_DATA_ROOT, 'words_freq.pickle'))
# 保存压缩后的词频
tmp = dict(U=self.freq_compress[0], sigma=self.freq_compress[1], VT=self.freq_compress[2])
tmp = np.array([tmp])
np.save(os.path.join(self.RECOMMEND_DATA_ROOT, 'words_freq_compress.npy'), tmp, allow_pickle=True)
def relate(self, post_id, N=5, method='svd'):
"""通过文章id求前N个与post_id相似的文章,可选计算方法同self.__call__"""
words = self.sentences.loc[self.sentences['id'] == post_id, 'words']
proj = self.__call__(words.iloc[0], N=N + 1, method=method)
return Post.objects.filter(id__in=proj[1:])
def get_title(self, posts_id):
"""通过文章的id获取文章名称,这个函数破坏了对象结构,看面子加上了"""
return [self.sentences.loc[self.sentences['id'] == pid, 'title'].item()
for pid in posts_id]
py

使用方法:
在post/views.py中的PostDetailView视图类中,获取该文章的推荐文章,如下。
class PostView(DetailView):
model = Post
template_name = 'post/post.html'
slug_field = 'post'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx.update({
'recommend_post_list': Recommend(datafrom='file').relate(ctx['post'].id, N=5, method='svd'),
})
return ctx
py

显示结果:

这里默认推荐前5相似的文章,可手动设置。
待优化
- 大数据情况未处理
- 非稀疏矩阵,性能没到最好
- 推荐准确度较低
