基于内容的推荐算法(Content-Based Recommendations,CB)是一种经典推荐算法,一般只依赖于用户及物品自身的内容属性和行为属性,而不涉及其他用户的行为,在冷启动的情况下(即新用户或者新物品)依然可以做出推荐。在本课题的题设条件下,我们难以找到显式反馈,在这种情况下,可以直接使用求职者的特征信息以及岗位信息的特征信息进行相似度计算,按照相似度度量降序排列后,对评分高的几项进行选取推荐。
基于内容的推荐算法使用最广泛的是K近邻算法(K-Nearest Neighbor,KNN),算法假设在同一个特征空间中,如果与目标样本最相似的K个样本(近邻)大多属于同一种类别,则目标样本属于这个类别的可能性也会很高。设 S m , n S_{m,n} Sm,n为求职者m与招聘信息n的相似度,一般相似度的度量有皮尔逊相似度、余弦相似度、杰卡德相似度和欧氏距离等。
基于内容的推荐算法是一个简单有效的推荐算法模型,原理质朴且易于复现,但是其有一定的局限性,该算法模型仅仅考虑了文本内容的相似度,对于信息的挖掘局限于文本信息本身,所以基于内容的推荐算法的思想并不能很好的解决本课题的重点与难点。
在传统的推荐算法领域中,为了充分挖掘相似人群的潜在物料的偏好信息,绝大部分情况下都采用基于用户的协同过滤算法(user -based collaborative filtering),协同是指通过用户的持续协同作用,使得物料对用户整个群体的推荐作用越来越明显,所谓过滤,指的是从可行的决策方案中将用户喜欢的方案挑出,在实际问题表现为从推荐方案中将用户匹配的物料从物料的集合中找出。
基于用户的协同过滤算法在对用户建模时通常使用一个用户对物料的评分矩阵。
Item1 | Item2 | Item3 | Item4 | |
---|---|---|---|---|
User1 | 4 | 0 | 2 | 2 |
User2 | 0 | 5 | 5 | 6 |
User3 | 6 | 2 | 1 | 2 |
User4 | 1 | 7 | 0 | 0 |
User-CF中,核心的考量是物料群体以及用户群体之间的相似度问题,一般取用cosine余弦相似度对行为向量衡量,设 v 1 v_1 v1为用户1在n维项目空间中的评分向量,设 v 2 v_2 v2为用户2在n维向量空间中的评分,余弦相似度如下式所示:
s i m ( v 1 , v 2 ) = v 1 ⋅ v 2 ∣ ∣ v 1 ∣ ∣ × ∣ ∣ v 2 ∣ ∣ sim\left(v_1,v_2\right)=\frac{v_1\cdot v_2}{\left|\left|v_1\right|\right|\times\left|\left|v_2\right|\right|} sim(v1,v2)=∣∣v1∣∣×∣∣v2∣∣v1⋅v2
产生推荐项目的过程也是推荐系统中的排序系统的任务,上述任务是召回任务,设用户i对物料d的评分为 R i , d R_{i,d} Ri,d,设用户j对所有物料的评分均值为 R j ˉ \bar{R_j} Rjˉ,设用户i对所有物料的评分均值为 R i ˉ \bar{R_i} Riˉ, S i S_i Si为用户i的邻用户集,那么加权平均后的评分值如下式所示,对于推荐项目的生成过程应用下式对未进行评分的用户进行评分预测,评分降序排列后取评分高的N个物料推荐给目标用户。
P i , d = R i ˉ + ∑ j ∈ S i s i m ( i , j ) ∗ ( R j , d − R j ˉ ) ∑ j ∈ S i ( ∣ s i m ( i , j ) ∣ ) P_{i,d}=\bar{R_i}+\frac{\sum_{j\in S_i}{sim\left(i,j\right)\ast\left(R_{j,d}-\bar{R_j}\right)}}{\sum_{j\in S_i}\left(\left|sim\left(i,j\right)\right|\right)} Pi,d=Riˉ+∑j∈Si(∣sim(i,j)∣)∑j∈Sisim(i,j)∗(Rj,d−Rjˉ)
在本题中可以建模为用户与招聘职位之间的协同算法,但是由于我们的爬取数据缺少用户与招聘信息之间的反馈交互信息,所以需要我们自行在用户关于岗位的描述信息以及岗位对于求职者的期望信息中挖掘关联度,进而间接进行评分以适应基于用户的协同过滤算法。
但是协同过滤算法具有一些局限性,其聚焦于相似群体的发现,不能关注到个性化的泛化结果,同时无论是基于用户的协同过滤算法还是基于物料(在本课题中反映为招聘信息)的协同过滤算法,都有其侧重的群体,这样的方式使得求职者与招聘岗位之间的推荐算法呈现了匹配分布的不对等性,即对于招聘岗位的抽样服从于求职者的随机变量取值为评分机制结果条件概率模型下的分布,而非具有两种约束的联合分布。对于这个问题在本课题中的考量只能靠再次使用基于物品的协同过滤算法,也即同时用基于用户和基于物品的协同过滤算法两个算法模型组合推荐评分,基于招聘信息的协同过滤算法偏好分析如下:
在评分的分布上寻找最近点以确定协同过滤算法的推荐项,保证对于求职者和岗位信息都有相似群体偏好的发现。
但是这种方法没有从根本上解决同时考虑求职者和招聘方的双向推荐的问题,只是对两个协同过滤算法的推荐方案进行了折中抽取,设k为协同过滤算法推荐项数,也即推荐匹配项生成后取排序靠前的k个项作为算法推荐方案,不能确定一个统一的k值保证重合匹配项的出现。
推荐系统一般分为召回系统和排序系统,向量化的神经网络参与的架构一般有两种形式,第一种是通过处理分别将求职者和招聘岗位的信息输入神经网络,输出求职者和招聘岗位的表征向量,经过混合运算,得到匹配分数,此种方式的架构如下图:
另外一种是将求职者信息和岗位招聘信息整体输入神经网络处理,直接预测出匹配得分结果。
双视角图表示学习算法属于第一种算法模型的范畴,由于算法需要一定的交互信息,所以在数据输入算法前需要对信息进行假设处理,我们在问题三中已经计算了岗位匹配度,岗位匹配度可以作为求职者与招聘信息发生互动的参考指标,即岗位匹配度能够间接反映用户与招聘信息发生互动的个人意愿,而用户满意度更多的是体现求职者对于互动意愿的期望。
双视角图表示学习算法对于求职者和岗位招聘信息的匹配使用双视角图神经网络(Duel-perspective Graph Neural Network),设 C = c 1 , c 2 , … , c n C=c_1,c_2,\ldots,c_n C=c1,c2,…,cn为求职者的集合, J = j 1 , j 2 , … , j m J=j_1,j_2,\ldots,j_m J=j1,j2,…,jm为招聘信息的集合, n n n表示求职者的数量, m m m表示招聘信息的数量,设匹配集 M = ( c i , j k ) ∣ c k ∈ C , j k ∈ J M=\left(c_i,j_k\right)|c_k\in C,j_k\in J M=(ci,jk)∣ck∈C,jk∈J表示求职者与招聘信息条目的双向匹配成功的集合,在算法中求职者和招聘信息都与其相关职位或者技能等相关描述信息关联,同时其与一组定向交互记录关联(即浏览、投递、满意三个指标),设交互集 A c i = c i → j ′ ∣ c i ∈ C , j ′ ∈ J A_{c_i}={c_i\rightarrow j^\prime|c_i\in C,j^\prime\in J} Aci=ci→j′∣ci∈C,j′∈J与分别为表示求职者i与招聘信息以及招聘信息k与求职者的直接交互行为,在交互行为的指标中,浏览可以转换交互行为建模语言为 c i → j k c_i\rightarrow j_k ci→jk,投递可以转换交互行为建模语言为 j k → c i j_k\rightarrow c_i jk→ci,而指标满意可以转换交互行为建模语言为 ( j k → c i ) ∪ ( c i → j k ) \left(j_k\rightarrow c_i\right)\cup\left(c_i\rightarrow j_k\right) (jk→ci)∪(ci→jk),设 为求职者 c i c_i ci对招聘岗位 j k j_k jk的选择偏好建模, s k → i s_{k\rightarrow i} sk→i为招聘岗位 j k j_k jk对求职者 c i c_i ci的选择偏好建模,算法的整体处理流程如图
根据算法流程以及结构,数据集要以求职者和招聘方相独立的形式逐条输入BERT进行编码工作,BERT的本质是一种文本表征(context representation),BERT的直观作用是将文本表达为矩阵或者向量,word2vec等工具也能够达到同样的效果,但是word2vec是静态的,而BERT是动态的,因为BERT是将输入考虑语序后经过transformer输出的,能够挖掘文本信息数据中的更多语义信息,对于每条的文本描述信息进行原有序列信息的保留的同时对文本信息进行信息首部 [ C L S ] t o k e n \left[CLS\right]token [CLS]token的添加,BERT结构的embedding过程如下图所示,每条求职者或者招聘方的信息都会经过BERT处理生成文本信息向量 (我们设BERT的输出向量维度为 d T d_T dT),为充分提取文本特征,达到反向传播机器学习的目的。对文本信息向量加入线性层得到输出维度为 d T d_T dT向量,同时对偏好ID(首次运行随机生成)使用embedding_lookup的方式生成编码向量 (我们设lookup方式生成的embedding维度为 ),将偏好ID的编码向量以及BERT处理经线性映射的向量进行连接操作从而得到神经网络处理的向量,由于输入BERT之前的数据文本是经过预处理过的,形式相对来说都是短文本的(低于512字),所以对于BERT的配置使用默认配置,embedding的大小使用768维,隐单元的个数配置为128个,可以满足本课题的模型效果要求。
双视角图表示学习算法的核心运作模块是图神经网络的模块,而图的数据结构必然有节点和边以及权重值的表达[31],对于节点,每个求职者或者招聘方都由两个节点组成,对于求职者c,其偏好有主动选择偏好(active selection preference)和被动选择偏好(active selection preference),记ca为求职者c的主动选择偏好、cp为求职者c的被动选择偏好,同样地,对于招聘方j也按照选择偏好分为两个节点ja和jp,两种节点代表了求职者(招聘方)在主动视角(对意向岗位或者意向求职者的主动选择)和被动视角(来自意向岗位或者意向求职者的被动选择),因此,在主动视角和被动视角的意愿同样强烈的情况下,能够生成最终的匹配。
数据输入章节所述的对于文本和偏好ID的向量化,是节点初始化工作的必要准备输入,对于节点n的初始化,需要将偏好ID编码向量与文本描述编码向量混合计算,节点n的初始化表征如下式:
z ( 0 ) n = [ e n ; W ⋅ t n ] ∈ R d {z^{\left(0\right)}}_n=\left[e_n;W\cdot t_n\right]\in\mathbb{R}^d z(0)n=[en;W⋅tn]∈Rd
其中 W ∈ R d T × d O W\in\mathbb{R}^{d_T\times d_O} W∈RdT×dO为可传播学习的变换矩阵,运算符 [ ; ] [;] [;]表示连接操作(concatenation), d = d E + d T d=d_{E}+d_{T} d=dE+dT 为节点的初始维度。
节点之间的边,是由不同节点的交互作用和同一求职者(招聘信息)节点的关联产生的,其特征是对称的,因此边的生成由以下三种情况组成:
(1)求职者投递简历但是未被录用,这反映了求职者的主动偏好,在 c a c^a ca和j p ^p p之间添加边。
(2)招聘岗位主动与求职者联系,但是求职者拒绝,这反映了招聘方的主动偏好,这种情况下在 j a j^a ja和 c p c^p cp之间加边。
(3)当求职者与招聘方达成协议,这反映了双向的主动偏好意向,增加 c a c^a ca与 j p j^p jp、 j a j^a ja与 c p c^p cp两条边。
基于双视角交互图的构建,我们采用图卷积网络对图结构数据建模进行处理,由于双视角交互图的求职者和招聘信息节点都有主动选择偏好视角节点和被动选择偏好视角节点两个节点,所以与通常的图卷积网络的构建方式有差异性[32]。而这种差异性来自于边集,与节点类型无关,所以在建模时可以忽略差异性进行统一建模,混合偏好传播的偏好只考虑匹配集M以及交互集A,对于节点n,由于统一建模的假设成立,所以忽略节点n的类型是求职者节点还是招聘信息节点、是主动选择偏好还是被动选择偏好,只考虑匹配集M_n以及交互集A_n,混合偏好传播的更新策略定义如下式:
z n ( l ) = ∑ u ∈ M n 1 ∣ N n ∣ ∣ N n ∣ z u ( l − 1 ) + ω ∑ v ∈ A n 1 ∣ N n ∣ ∣ N n ∣ z v ( l − 1 ) z_n^{\left(l\right)}=\sum_{u\in M_n}\frac{1}{\sqrt{\left|N_n\right|\left|N_n\right|}}z_u^{\left(l-1\right)}+\omega\sum_{v\in A_n}{\frac{1}{\sqrt{\left|N_n\right|\left|N_n\right|}}z_v^{\left(l-1\right)}} zn(l)=u∈Mn∑∣Nn∣∣Nn∣1zu(l−1)+ωv∈An∑∣Nn∣∣Nn∣1zv(l−1)
其中, 为节点n在匹配集 M n M_n Mn的邻节点, 为节点 n n n在交互集 A n A_n An中的邻节点,超参数 ω \omega ω为传达不同级别偏好的作用参数,用以平衡来自匹配集和交互集的传播更新影响力。
在图卷积网络中,第L+1层的节点n的最终更新策略结果如下式:
z n = ∑ l = 0 L 1 L + 1 z n l z_n=\sum_{l=0}^{L}{\frac{1}{L+1}z_n^l} zn=l=0∑LL+11znl
基于交互图的构建和混合偏好传播策略的确定,我们提出一种对于双向意图预测的评价得分指标,这个指标也是问题三中的求职者满意度的指标。
对于求职者 c i c_i ci选择招聘信息 j k j_k jk的偏好意向得分 定义为内积形式 ,对于招聘信息 j k j_k jk选择求职者 c i c_i ci的偏好意向得分 同样定义为内积形式 ,出于对求职者和招聘方的意图同等重要的看待,双向意图预测的评价得分的定义如下式:
y ^ = 1 2 r i → k + 1 2 s k → i \hat{y}=\frac{1}{2}r_{i\rightarrow k}+\frac{1}{2}s_{k\rightarrow i} y^=21ri→k+21sk→i
为了优化求职者与招聘信息的匹配预测结果过拟合和发散问题,采用基于排序优化的方法优化整个模型的过程,通过定义四元损失函数的方式优化正样本和负样本距离的偏阶;通过采用双视角对比学习的方式,为求职者和招聘信息的两种表示设计了双视角对比学习的优化函数,通过最大化一致性和最小化差异性的方式建立了 I n f o N C E InfoNCE InfoNCE损失的定义,结合求职者和招聘信息的 I n f o N C E L o s s InfoNCE Loss InfoNCELoss,最终实现了自监督的效果。
在给定一个匹配记录 < c i , j k >
f ( c i , j k ) > f ( c i , j k ′ ) f ( c i , j k ) > f ( c i ′ , j k ) f\left(c_i,j_k\right)>f\left(c_i,j_{k^\prime}\right)\\ f\left(c_i,j_k\right)>f\left(c_{i^\prime},j_k\right) f(ci,jk)>f(ci,jk′)f(ci,jk)>f(ci′,jk)
基于上述思想,使用BRP损失扩展,对四元组 < i , k , i ′ , k ′ > <i,k,i′,k′>建模损失如下式:
L m a i n = − 1 ∣ D ∣ ∑ ( i , k , i ′ , k ′ ) ∈ D l o g ( σ ( y ^ i , k − 1 2 y ^ i , k ′ − 1 2 y ^ i ′ , k ) ) L_{main}=-\frac{1}{\left|D\right|}\sum_{\left(i,k,i^\prime,k^\prime\right)\in D} l o g\left(\sigma\left({\hat{y}}_{i,k}-\frac{1}{2}{\hat{y}}_{i,k^\prime}-\frac{1}{2}{\hat{y}}_{i^\prime,k}\right)\right) Lmain=−∣D∣1(i,k,i′,k′)∈D∑log(σ(y^i,k−21y^i,k′−21y^i′,k))
其中 为 函数,设 M − M^- M−为不匹配集, D D D为定义在匹配集 M M M和交互集 A A A上的四元组 , s i g m o i d sigmoid sigmoid函数在处理来自匹配集 M M M中的分数 与来自不匹配集 的分数 y ^ i , k ′ {\hat{y}}_{i,k^\prime} y^i,k′、 的均值的偏差,通过反向传播使得损失减小从而达到优化生成算法 f f f不等式的偏阶。
在考虑求职者与招聘信息的两种表示时,应当在性质上相似的自然约束条件下,通过上述思路设计了一个双视角对比学习优化函数,考虑将 与 、 与 作为正对(positive pairs),考虑将 与 、 与 作为负对(negative pairs),正对促进了不同视角的一致性,而负对扩大了不同视角的差异性,应用 I n f o N C E InfoNCE InfoNCE损失来最大化一致性、最小化差异,求职者的 I n f o N C E L o s s InfoNCE\quad Loss InfoNCELoss定义如下式:
L s s l C = − ∑ c i ∈ C l o g ( e x p ( ( c i a ⋅ c i p ) / τ ) ∑ c i ∈ C ( e x p ( ( c i a ⋅ c i ′ p ) / τ ) + e x p ( ( c i ′ a ⋅ c i p ) / τ ) ) ) L_{ssl}^C=-\sum_{c_i\in C} l o g\left(\frac{exp\left({(c}_i^a\cdot c_i^p)/\tau\right)}{\sum_{c_i\in C}\left(exp\left(\left(c_i^a\cdot c_{i\prime}^p\right)/\tau\right)+exp\left(\left(c_{i\prime}^a\cdot c_i^p\right)/\tau\right)\right)}\right) LsslC=−ci∈C∑log(∑ci∈C(exp((cia⋅ci′p)/τ)+exp((ci′a⋅cip)/τ))exp((cia⋅cip)/τ))
其中, τ \tau τ为温度超参数,式是求职者的 I n f o N C E InfoNCE InfoNCE损失定义,那么同样可以得到招聘信息的 损失,将两者结合,就得到了最终的自监督任务的 I n f o N C E InfoNCE InfoNCE损失,如下式:
L s s l = L s s l C + L s s l J L_{ssl}=L_{ssl}^C+L_{ssl}^J Lssl=LsslC+LsslJ
在图神经网络的训练阶段,设\lambda为个体层面的对比学习任务强度超参数,同时优化上述所有损失,训练阶段的损失如下式所示:
L = L m a i n + λ L s s l L=L_{main}+\lambda L_{ssl} L=Lmain+λLssl
作者开源项目地址https://github.com/RUCAIBox/DPGNN
DPGNN参数配置
# Model
embedding_size: 128
n_layers: 2
reg_weight: 1e-05
mutual_weight: 0.05
temperature: 0.2
ADD_BERT: True
pretrained_bert_model: ~
BERT_embedding_size: 768
BERT_output_size: 32
数据预处理转换
# .udoc
import re
def split_sent(text):
text = re.split('(?:[0-9][.;。:.•)\)])', text) # 按照数字分割包括 1. 1; 1。 1: 1) 等
ans = []
for t in text:
for tt in re.split('(?:[\ ][0-9][、,])', t): #
for ttt in re.split('(?:^1[、,])', tt): # 1、
for tttt in re.split('(?:\([0-9]\))', ttt): # (1)
ans += re.split('(?:[。;…●])', tttt)
return [_.strip() for _ in ans if len(_.strip()) > 0]
def cut_sent(text):
wds = [_.strip() for _ in text.split(' ') if len(_.strip()) > 0] # 分词,返回分词后的 list
return wds
def clean_text(text):
illegal_set = ',.;?!~[]\'"@#$%^&*()-_=+{}`~·!¥()—「」【】|、“”《<》>?,。…:' # 定义非法字符
for c in illegal_set:
text = text.replace(c, ' ') # 非法字符 替换为 空格
text = ' '.join([_ for _ in text.split(' ') if len(_) > 0])
return text # 空格间隔
def raw2token_seq(s):
sents = split_sent(s)
sent_wds = []
sent_lens = []
for sent in sents:
if len(sent) < 2:
continue
sent = clean_text(sent)
if len(sent) < 1:
continue
wds = cut_sent(sent)
sent_wds.extend(wds)
sent_lens.append(len(wds))
if len(sent_wds) < 1:
return None, None, None
assert sum(sent_lens) == len(sent_wds)
# 返回3个值,第一个是用空格连接的词,第二个是各句子长度,第三个是总词数
return ' '.join(sent_wds), ' '.join(map(str, sent_lens)), len(sent_wds)
u_doc_index = [3, 4, 5, 7, 8]
f = open('users.tsv', 'r', encoding='utf-8')
f.readline()
f_his = open('user_history.tsv', 'r', encoding='utf-8')
f_his.readline()
f_udoc = open('jobrec.udoc', 'w', encoding='utf-8')
head = ['user_id:token', 'user_doc:token_seq']
f_udoc.write('\t'.join(head) + '\n')
for line in f_his:
lines = line[:-1].split('\t')
sents = lines[4]
try:
sent_wds, sent_lens, _ = raw2token_seq(sents)
sent_wds = sent_wds.split(' ')
sent_lens = sent_lens.split(' ')
a = -(int)(sent_lens[-1])
for j in range(len(sent_lens)):
a += (int)(sent_lens[j - 1])
s_word_line = ' '.join(sent_wds[a:a + (int)(sent_lens[j])])
if s_word_line == '':
continue
s_new_line = lines[0] + '\t' + s_word_line + '\n'
f_udoc.write(s_new_line)
except:
if sents == '':
continue
f_udoc.write(lines[0] + '\t' + sents + '\n')
for line in f:
lines = line[:-1].split('\t')
for i in u_doc_index:
if lines[i] and lines[i] != '-':
sents = lines[i]
try:
sent_wds, sent_lens, _ = raw2token_seq(sents)
sent_wds = sent_wds.split(' ')
sent_lens = sent_lens.split(' ')
a = -(int)(sent_lens[-1])
for j in range(len(sent_lens)):
a += (int)(sent_lens[j - 1])
s_word_line = ' '.join(sent_wds[a:a + (int)(sent_lens[j])])
s_new_line = lines[0] + '\t' + s_word_line + '\n'
f_udoc.write(s_new_line)
except:
f_udoc.write(lines[0] + '\t' + sents + '\n')
f.close()
f_his.close()
f_udoc.close()
# .idoc
import re
i_doc_index = [2, 3, 4]
f = open('jobs.tsv', 'r', encoding='utf-8')
f.readline()
f_idoc = open('jobrec.idoc', 'w', encoding='utf-8')
head = ['item_id:token', 'item_doc:token_seq']
f_idoc.write('\t'.join(head) + '\n')
for line in f:
lines = line[:-1].split('\t')
for i in i_doc_index:
if lines[i] and lines[i] != '-':
sents = lines[i]
sents = re.sub('\\\\r', ' ', sents)
sents = re.sub('<[^>]+>', ' ', sents)
sents = re.sub(' ', ' ', sents)
sents = sents.split(' ')
for sent in sents:
sent = sent.strip()
if len(sent) < 20:
continue
s_new_line = lines[0] + '\t' + sent + '\n'
f_idoc.write(s_new_line)
f.close()
f_idoc.close()
from collections import defaultdict
f = open('jobrec.udoc', encoding='utf-8')
f.readline()
u_doc_num = defaultdict(int)
for l in f:
lines = l.split('\t')
u_doc_num[lines[0]] += 1
u_doc = sorted(u_doc_num.items(), key=lambda x: x[1])
count = 1
for i in u_doc:
if i[1] < 10:
count += 1
print(count)
from collections import defaultdict
f = open('jobrec.idoc', encoding='utf-8')
f.readline()
i_doc_num = defaultdict(int)
for l in f:
lines = l.split('\t')
i_doc_num[lines[0]] += 1
i_doc = sorted(i_doc_num.items(), key=lambda x: x[1])
count = 1
for i in i_doc:
if i[1] < 10:
count += 1
print(count)
USER_THRESHOLD = 10
JOB_THRESHOLD = 20
# .inter
import time
def get_timestamp(timeStr):
timeArray = time.strptime(timeStr, '%Y-%m-%d %H:%M:%S')
timeStamp = int(time.mktime(timeArray))
return timeStamp
f_inter = open('apps.tsv', 'r', encoding='utf-8')
f_inter_target = open('jobrec.inter', 'w', encoding='utf-8')
f_inter_target.write('user_id:token\titem_id:token\ttimestamp:float\n')
f_inter.readline()
user = set()
job = set()
l = f_inter.readline()
while l:
lines = l.split('\t')
uid = lines[0]
iid = lines[-1][:-1]
new_line = lines[0] + '\t' + lines[-1][:-1] + '\t' + str(get_timestamp(lines[-2][:19])) + '\n'
if u_doc_num[lines[0]] > USER_THRESHOLD and i_doc_num[lines[0]] > JOB_THRESHOLD:
user.add(uid)
job.add(iid)
f_inter_target.write(new_line)
l = f_inter.readline()
f_inter.close()
f_inter_target.close()
# .user
u_index = [0, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
f_user = open('users.tsv', 'r', encoding='utf-8')
f_user_target = open('jobrec.user', 'w', encoding='utf-8')
head = f_user.readline()[:-1].split('\t')
head = [head[i] for i in u_index]
head = [i + ':token' for i in head]
head[0] = 'user_id:token'
f_user_target.write('\t'.join(head) + '\n')
l = f_user.readline()
while l:
lines = l[:-1].split('\t')
lines = [lines[i] for i in u_index]
new_line = '\t'.join(lines) + '\n'
if lines[0] in user and u_doc_num[lines[0]] > USER_THRESHOLD:
f_user_target.write(new_line)
l = f_user.readline()
f_user.close()
f_user_target.close()
# .item
i_index = [0, 5, 6, 7, 8]
f_item = open('jobs.tsv', 'r', encoding='utf-8')
f_item_target = open('jobrec.item', 'w', encoding='utf-8')
head = f_item.readline()[:-1].split('\t')
head = [head[i] for i in i_index]
head = [i + ':token' for i in head]
head[0] = 'item_id:token'
f_item_target.write('\t'.join(head) + '\n')
l = f_item.readline()
while l:
lines = l.split('\t')
lines = [lines[i] for i in i_index]
new_line = '\t'.join(lines) + '\n'
if lines[0] in job and i_doc_num[lines[0]] > JOB_THRESHOLD:
f_item_target.write(new_line)
l = f_item.readline()
f_item.close()
f_item_target.close()
# .udoc
import re
def split_sent(text):
text = re.split('(?:[0-9][.;。:.•)\)])', text) # 按照数字分割包括 1. 1; 1。 1: 1) 等
ans = []
for t in text:
for tt in re.split('(?:[\ ][0-9][、,])', t): #
for ttt in re.split('(?:^1[、,])', tt): # 1、
for tttt in re.split('(?:\([0-9]\))', ttt): # (1)
ans += re.split('(?:[。;…●])', tttt)
return [_.strip() for _ in ans if len(_.strip()) > 0]
def cut_sent(text):
wds = [_.strip() for _ in text.split(' ') if len(_.strip()) > 0] # 分词,返回分词后的 list
return wds
def clean_text(text):
illegal_set = ',.;?!~[]\'"@#$%^&*()-_=+{}`~·!¥()—「」【】|、“”《<》>?,。…:' # 定义非法字符
for c in illegal_set:
text = text.replace(c, ' ') # 非法字符 替换为 空格
text = ' '.join([_ for _ in text.split(' ') if len(_) > 0])
return text # 空格间隔
def raw2token_seq(s):
sents = split_sent(s)
sent_wds = []
sent_lens = []
for sent in sents:
if len(sent) < 2:
continue
sent = clean_text(sent)
if len(sent) < 1:
continue
wds = cut_sent(sent)
sent_wds.extend(wds)
sent_lens.append(len(wds))
if len(sent_wds) < 1:
return None, None, None
assert sum(sent_lens) == len(sent_wds)
# 返回3个值,第一个是用空格连接的词,第二个是各句子长度,第三个是总词数
return ' '.join(sent_wds), ' '.join(map(str, sent_lens)), len(sent_wds)
u_doc_index = [3, 4, 5, 7, 8]
f = open('users.tsv', 'r', encoding='utf-8')
f.readline()
f_his = open('user_history.tsv', 'r', encoding='utf-8')
f_his.readline()
f_udoc = open('jobrec.udoc', 'w', encoding='utf-8')
head = ['user_id:token', 'user_doc:token_seq']
f_udoc.write('\t'.join(head) + '\n')
for line in f_his:
lines = line[:-1].split('\t')
sents = lines[4]
try:
sent_wds, sent_lens, _ = raw2token_seq(sents)
sent_wds = sent_wds.split(' ')
sent_lens = sent_lens.split(' ')
a = -(int)(sent_lens[-1])
for j in range(len(sent_lens)):
a += (int)(sent_lens[j - 1])
s_word_line = ' '.join(sent_wds[a:a + (int)(sent_lens[j])])
if s_word_line == '':
continue
s_new_line = lines[0] + '\t' + s_word_line + '\n'
f_udoc.write(s_new_line)
except:
if sents == '':
continue
f_udoc.write(lines[0] + '\t' + sents + '\n')
for line in f:
lines = line[:-1].split('\t')
if u_doc_num[lines[0]] > USER_THRESHOLD:
for i in u_doc_index:
if lines[i] and lines[i] != '-':
sents = lines[i]
try:
sent_wds, sent_lens, _ = raw2token_seq(sents)
sent_wds = sent_wds.split(' ')
sent_lens = sent_lens.split(' ')
a = -(int)(sent_lens[-1])
for j in range(len(sent_lens)):
a += (int)(sent_lens[j - 1])
s_word_line = ' '.join(sent_wds[a:a + (int)(sent_lens[j])])
s_new_line = lines[0] + '\t' + s_word_line + '\n'
f_udoc.write(s_new_line)
except:
f_udoc.write(lines[0] + '\t' + sents + '\n')
f.close()
f_his.close()
f_udoc.close()
# .idoc
import re
i_doc_index = [2, 3, 4]
f = open('jobs.tsv', 'r', encoding='utf-8')
f.readline()
f_idoc = open('jobrec.idoc', 'w', encoding='utf-8')
head = ['item_id:token', 'item_doc:token_seq']
f_idoc.write('\t'.join(head) + '\n')
for line in f:
lines = line[:-1].split('\t')
if i_doc_num[lines[0]] > JOB_THRESHOLD:
for i in i_doc_index:
if lines[i] and lines[i] != '-':
sents = lines[i]
sents = re.sub('\"', '', sents)
sents = re.sub('\\\\r', ' ', sents)
sents = re.sub('<[^>]+>', ' ', sents)
sents = re.sub(' ', ' ', sents)
sents = sents.split(' ')
for sent in sents:
sent = sent.strip()
if len(sent) < 20:
continue
s_new_line = lines[0] + '\t' + sent + '\n'
f_idoc.write(s_new_line)
f.close()
f_idoc.close()
DPGNN网络结构定义、网络传播、损失定义:
# @Time : 2022/3/21
# @Author : Chen Yang
# @Email : [email protected]
"""
pjfbole
"""
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.nn.init import xavier_normal_
from recbole.model.abstract_recommender import GeneralRecommender
from recbole.model.loss import BPRLoss, EmbLoss
from recbole.utils import InputType
class DPGNN(GeneralRecommender):
input_type = InputType.PAIRWISE
def __init__(self, config, dataset):
super(DPGNN, self).__init__(config, dataset)
from recbole_pjf.model.layer import GCNConv
self.NEG_USER_ID = config['NEG_PREFIX'] + self.USER_ID
self.NEG_ITEM_ID = config['NEG_PREFIX'] + self.ITEM_ID
# load parameters info
self.embedding_size = config['embedding_size']
# load dataset info
self.interaction_matrix = dataset.inter_matrix(form='coo').astype(np.float32)
self.user_add_matrix = dataset.user_single_inter_matrix(form='coo').astype(np.float32)
self.job_add_matrix = dataset.item_single_inter_matrix(form='coo').astype(np.float32)
# load parameters info
self.latent_dim = config['embedding_size'] # int type:the embedding size of lightGCN
self.n_layers = config['n_layers'] # int type:the layer num of lightGCN
self.reg_weight = config['reg_weight'] # float32 type: the weight decay for l2 normalization
self.mul_weight = config['mutual_weight']
self.temperature = config['temperature']
# layers
self.user_embedding_a = nn.Embedding(self.n_users, self.latent_dim)
self.item_embedding_a = nn.Embedding(self.n_items, self.latent_dim)
self.user_embedding_p = nn.Embedding(self.n_users, self.latent_dim)
self.item_embedding_p = nn.Embedding(self.n_items, self.latent_dim)
self.gcn_conv = GCNConv(dim=self.latent_dim)
self.mf_loss = BPRLoss()
self.reg_loss = EmbLoss()
self.mutual_loss = nn.CrossEntropyLoss()
self.loss = 0
# bert part
self.ADD_BERT = config['ADD_BERT']
self.BERT_e_size = config['BERT_output_size'] or 1
self.bert_lr = nn.Linear(config['BERT_embedding_size'], self.BERT_e_size)
if self.ADD_BERT:
self.bert_user = dataset.bert_user.to(config['device'])
self.bert_item = dataset.bert_item.to(config['device'])
# generate intermediate data
self.edge_index, self.edge_weight = self.get_norm_adj_mat()
self.edge_index = self.edge_index.to(config['device'])
self.edge_weight = self.edge_weight.to(config['device'])
self.apply(self._init_weights)
def _init_weights(self, module):
if isinstance(module, nn.Embedding):
xavier_normal_(module.weight.data)
def get_norm_adj_mat(self):
r"""Get the normalized interaction matrix of users and items.
Construct the square matrix from the training data and normalize it
using the laplace matrix.
.. math::
A_{hat} = D^{-0.5} \times A \times D^{-0.5}
Returns:
The normalized interaction matrix in Tensor.
"""
# user a node: [0 ~~~ n_users] (len: n_users)
# item p node: [n_users + 1 ~~~ n_users + 1 + n_items] (len: n_users)
# user p node: [~] (len: n_users)
# item a node: [~] (len: n_items)
from torch_geometric.utils import degree
n_all = self.n_users + self.n_items
# success edge
row = torch.LongTensor(self.interaction_matrix.row)
col = torch.LongTensor(self.interaction_matrix.col) + self.n_users
edge_index1 = torch.stack([row, col])
edge_index2 = torch.stack([col, row])
edge_index3 = torch.stack([row + n_all, col + n_all])
edge_index4 = torch.stack([col + n_all, row + n_all])
edge_index_suc = torch.cat([edge_index1, edge_index2, edge_index3, edge_index4], dim=1)
# user_add edge
row = torch.LongTensor(self.user_add_matrix.row)
col = torch.LongTensor(self.user_add_matrix.col) + self.n_users
edge_index1 = torch.stack([row, col])
edge_index2 = torch.stack([col, row])
edge_index_user_add = torch.cat([edge_index1, edge_index2], dim=1)
# job_add edge
row = torch.LongTensor(self.job_add_matrix.row)
col = torch.LongTensor(self.job_add_matrix.col) + self.n_users
edge_index1 = torch.stack([row + n_all, col + n_all])
edge_index2 = torch.stack([col + n_all, row + n_all])
edge_index_job_add = torch.cat([edge_index1, edge_index2], dim=1)
# self edge
geek = torch.LongTensor(torch.arange(0, self.n_users))
job = torch.LongTensor(torch.arange(0, self.n_items) + self.n_users)
edge_index_geek_1 = torch.stack([geek, geek + n_all])
edge_index_geek_2 = torch.stack([geek + n_all, geek])
edge_index_job_1 = torch.stack([job, job + n_all])
edge_index_job_2 = torch.stack([job + n_all, job])
edge_index_self = torch.cat([edge_index_geek_1, edge_index_geek_2, edge_index_job_1, edge_index_job_2], dim=1)
# all edge
edge_index = torch.cat([edge_index_suc, edge_index_user_add, edge_index_job_add, edge_index_self], dim=1)
deg = degree(edge_index[0], (self.n_users + self.n_items) * 2)
norm_deg = 1. / torch.sqrt(torch.where(deg == 0, torch.ones([1]), deg))
edge_weight = norm_deg[edge_index[0]] * norm_deg[edge_index[1]]
return edge_index, edge_weight
def get_ego_embeddings(self):
r"""Get the embedding of users and items and combine to an embedding matrix.
Returns:
Tensor of the embedding matrix. Shape of [n_items+n_users, embedding_dim]
"""
user_embeddings_a = self.user_embedding_a.weight
item_embeddings_a = self.item_embedding_a.weight
user_embeddings_p = self.user_embedding_p.weight
item_embeddings_p = self.item_embedding_p.weight
ego_embeddings = torch.cat([user_embeddings_a,
item_embeddings_p,
user_embeddings_p,
item_embeddings_a], dim=0)
if self.ADD_BERT:
self.bert_u = self.bert_lr(self.bert_user)
self.bert_i = self.bert_lr(self.bert_item)
bert_e = torch.cat([self.bert_u,
self.bert_i,
self.bert_u,
self.bert_i], dim=0)
return torch.cat([ego_embeddings, bert_e], dim=1)
return ego_embeddings
def info_nce_loss(self, index, is_user):
all_embeddings = self.get_ego_embeddings()
user_e_a, item_e_p, user_e_p, item_e_a = torch.split(all_embeddings,
[self.n_users, self.n_items, self.n_users, self.n_items])
if is_user:
u_e_a = F.normalize(user_e_a[index], dim=1)
u_e_p = F.normalize(user_e_p[index], dim=1)
similarity_matrix = torch.matmul(u_e_a, u_e_p.T)
else:
i_e_a = F.normalize(item_e_a[index], dim=1)
i_e_p = F.normalize(item_e_p[index], dim=1)
similarity_matrix = torch.matmul(i_e_a, i_e_p.T)
mask = torch.eye(index.shape[0], dtype=torch.bool).to(self.device)
positives = similarity_matrix[mask].view(index.shape[0], -1)
negatives = similarity_matrix[~mask].view(index.shape[0], -1)
logits = torch.cat([positives, negatives], dim=1)
labels = torch.zeros(logits.shape[0], dtype=torch.long).to(self.device)
logits = logits / self.temperature
return logits, labels
def forward(self):
all_embeddings = self.get_ego_embeddings()
embeddings_list = [all_embeddings]
for layer_idx in range(self.n_layers):
all_embeddings = self.gcn_conv(all_embeddings, self.edge_index, self.edge_weight)
embeddings_list.append(all_embeddings)
lightgcn_all_embeddings = torch.stack(embeddings_list, dim=1)
lightgcn_all_embeddings = torch.mean(lightgcn_all_embeddings, dim=1)
user_e_a, item_e_p, user_e_p, item_e_a = torch.split(lightgcn_all_embeddings,
[self.n_users, self.n_items, self.n_users, self.n_items])
return user_e_a, item_e_p, user_e_p, item_e_a
def calculate_loss(self, interaction):
user = interaction[self.USER_ID]
item = interaction[self.ITEM_ID]
neg_user = interaction[self.NEG_USER_ID]
neg_item = interaction[self.NEG_ITEM_ID]
user_e_a, item_e_p, user_e_p, item_e_a = self.forward()
# user active
u_e_a = user_e_a[user]
n_u_e_a = user_e_a[neg_user]
# item negative
i_e_p = item_e_p[item]
n_i_e_p = item_e_p[neg_item]
# user negative
u_e_p = user_e_p[user]
n_u_e_p = user_e_p[neg_user]
# item active
i_e_a = item_e_a[item]
n_i_e_a = item_e_a[neg_item]
r_pos = torch.mul(u_e_a, i_e_p).sum(dim=1)
s_pos = torch.mul(u_e_p, i_e_a).sum(dim=1)
r_neg1 = torch.mul(u_e_a, n_i_e_p).sum(dim=1)
s_neg1 = torch.mul(u_e_p, n_i_e_a).sum(dim=1)
r_neg2 = torch.mul(n_u_e_a, i_e_p).sum(dim=1)
s_neg2 = torch.mul(n_u_e_p, i_e_a).sum(dim=1)
mf_loss_u = self.mf_loss(2 * r_pos + 2 * s_pos, r_neg1 + s_neg1 + r_neg2 + s_neg2)
# calculate Emb Loss
u_ego_embeddings_a = self.user_embedding_a(user)
u_ego_embeddings_p = self.user_embedding_p(user)
pos_ego_embeddings_a = self.item_embedding_a(item)
pos_ego_embeddings_p = self.item_embedding_p(item)
neg_ego_embeddings_a = self.item_embedding_a(neg_item)
neg_ego_embeddings_p = self.item_embedding_p(neg_item)
neg_u_ego_embeddings_a = self.user_embedding_a(neg_user)
neg_u_ego_embeddings_p = self.user_embedding_p(neg_user)
reg_loss = self.reg_loss(u_ego_embeddings_a, u_ego_embeddings_p,
pos_ego_embeddings_a, pos_ego_embeddings_p,
neg_ego_embeddings_a, neg_ego_embeddings_p,
neg_u_ego_embeddings_a, neg_u_ego_embeddings_p)
loss = mf_loss_u + self.reg_weight * reg_loss
logits_user, labels = self.info_nce_loss(user, is_user=True)
loss += self.mul_weight * self.mutual_loss(logits_user, labels)
logits_job, labels = self.info_nce_loss(item, is_user=False)
loss += self.mul_weight * self.mutual_loss(logits_job, labels)
return loss
def predict(self, interaction):
user = interaction[self.USER_ID]
item = interaction[self.ITEM_ID]
user_e_a, item_e_p, user_e_p, item_e_a = self.forward()
# user activate
u_e_a = user_e_a[user]
# item negative
i_e_p = item_e_p[item]
# user negative
u_e_p = user_e_p[user]
# item negative
i_e_a = item_e_a[item]
I_geek = torch.mul(u_e_a, i_e_p).sum(dim=1)
I_job = torch.mul(u_e_p, i_e_a).sum(dim=1)
# calculate BPR Loss
scores = I_geek + I_job
return scores
Yang C, Hou Y, Song Y, et al. Modeling Two-Way Selection Preference for Person-Job Fit[C]//Proceedings of the 16th ACM Conference on Recommender Systems. 2022: 102-112.