只要開始,任何時候都不算晚。最近打算把 KGE 的模型從頭到尾梳理一遍。即使很多人都建議直接看頂會最新文章,但我還是沒辦法沒有能力那么做。萬丈高樓平地起,我仍然堅持認為打好基礎是最重要的。決心進入這個領域,不把它的前因后果、歷史脈絡搞清楚,就沒辦法構建自己的知識體系,有了知識體系,去看任意一篇新的論文,都可以對應到知識體系中的位置,輕而易舉地融匯貫通,否則看論文就是狗熊掰棒子。克強總理說要青年學生要加強基礎知識的學習,深以為然。
這篇整理 TransE、TransH 和 TransR,后續持續更新,希望博客園盡快完成整改,讓我好發博文。
TransE
paper: Translating Embeddings for Modeling Multi-relational Data
論文
大家都非常熟悉的 TransE 是知識圖譜表示學習的開山之作。由 Antoine Bordes 發表於 2013 年的 NIPS(現 NeurIPS)上。TransE 中的 E 代表 embedding。論文的主體思想是:將關系視為低維向量空間中的頭實體到尾實體的翻譯操作,即 \(h+r \approx t\)。因為比較簡單,直接貼公式了。
三元組打分函數:


其中,\(d(\cdot)\) 函數定義為:

由於歸一化的約束,上式可以簡化為:

文章中也說了,與 NTN 殊途同歸。
訓練算法如下,使用 SGD 優化:

實驗的話,只進行了 Link Prediction 和尾實體預測的 case study,沒有三元組分類。



代碼
\(Pykg2vec\) 用 PyTorch 實現了很多 KGE 模型,學習一下它的源碼。統共就三個函數,還是比較簡單的。
class TransE(PairwiseModel):
def __init__(self, **kwargs):
super(TransE, self).__init__(self.__class__.__name__.lower())
param_list = ["tot_entity", "tot_relation", "hidden_size", "l1_flag"]
param_dict = self.load_params(param_list, kwargs)
self.__dict__.update(param_dict)
self.ent_embeddings = NamedEmbedding("ent_embedding", self.tot_entity, self.hidden_size)
self.rel_embeddings = NamedEmbedding("rel_embedding", self.tot_relation, self.hidden_size)
nn.init.xavier_uniform_(self.ent_embeddings.weight)
nn.init.xavier_uniform_(self.rel_embeddings.weight)
self.parameter_list = [
self.ent_embeddings,
self.rel_embeddings,
]
self.loss = Criterion.pairwise_hinge
def forward(self, h, r, t):
"""Function to get the embedding value.
Args:
h (Tensor): Head entities ids.
r (Tensor): Relation ids.
t (Tensor): Tail entity ids.
Returns:
Tensors: the scores of evaluationReturns head, relation and tail embedding Tensors.
"""
h_e, r_e, t_e = self.embed(h, r, t)
norm_h_e = F.normalize(h_e, p=2, dim=-1)
norm_r_e = F.normalize(r_e, p=2, dim=-1)
norm_t_e = F.normalize(t_e, p=2, dim=-1)
if self.l1_flag:
return torch.norm(norm_h_e + norm_r_e - norm_t_e, p=1, dim=-1)
return torch.norm(norm_h_e + norm_r_e - norm_t_e, p=2, dim=-1)
def embed(self, h, r, t):
"""Function to get the embedding value.
Args:
h (Tensor): Head entities ids.
r (Tensor): Relation ids.
t (Tensor): Tail entity ids.
Returns:
Tensors: Returns a tuple of head, relation and tail embedding Tensors.
"""
h_e = self.ent_embeddings(h)
r_e = self.rel_embeddings(r)
t_e = self.ent_embeddings(t)
return h_e, r_e, t_e
TransE 雖然簡單,但是很有效,計算復雜度低,參數少,Mean Rank 能降到一二百已經很不錯了,所以感覺后面的模型似乎都是在蹭它的熱度,本身的效果提升感覺並不是很大,我自己在訓練模型時也總感覺 TransE 很難超越,這就是經典吧。
TransH
paper: Knowledge Graph Embedding by Translating on Hyperplanes
論文
文章由中山大學與微軟的研究者發表在 AAAI 2014 上。文章的主要賣點是解決 TransE 不能很好地處理 1-n、n-1 及 n-n 這樣的復雜關系的問題,因此在整體數據集上 Link Prediction 的提升並不大,而在 relation category 的預測准確率有清一色的提升。TransH 中的 H 代表 Hyperplane(超平面)。
主體方法
方法的本質是: \(h\) 和 \(r\) 還是用一個向量表示,而 \(r\) 用兩個向量表示。文章最先提出 \(unif\) 和 \(bern\) 的負采樣方法。

TransH 將 \(h\) 和 \(t\) 投影到 \(r\) 所在的超平面上,投影操作通過下式計算:

然后用投影后的頭尾實體計算三元組得分:

\(d_r\) 為關系 \(r\) 的 translation vector,即其本身的 embedding, \(w_r\) 為 normal vector,用於確定 hyperplane,即用於頭尾實體的投影。
TransH 對實體和關系的向量加了很多的約束,因此 Loss 略顯臃腫,有的實現中說正交約束作用並不大,可以不加。訓練同樣使用 SGD。

負采樣方法
文章另一個創新點是提出了新的采樣方法 \(bern\)。原始的負采樣方法是從實體集中隨機抽取一個實體替換到 golden triplet 中生成負樣本,但是這樣做有可能會得到假陽(false negative)的負樣本。對於這種情況,文章的解決策略是:對於 1-N 的關系,賦予更高的概率替換頭實體,而對於 N-1 的關系,賦予更高的概率替換尾實體。具體地,對每個關系計算其 \(tph\) (每個頭實體平均對應幾個尾實體)和 \(hpt\) (每個尾實體平均對應幾個頭實體)。對於 \(\frac{tph}{tph+hpt}\) 越大的,說明是一對多的關系,在負采樣時替換頭實體,更容易獲得 true negative。我曾經思考過這個問題,如果在普通的 \(unif\) 采樣時,加一個檢驗,看下生成的負樣本是否存在於 KG 中,這樣是不是就可以避免生成 false negative?但是這樣的策略默認遵從了一個假設,即 KG 之外的知識全都是錯誤的(即封閉世界假定 Closed World Assumption),即使生成的負樣本不存在於訓練集中,也不代表它就是 negative 的,而一般 KG 訓練的時候遵循的是開放世界假定(Open World Assumption, OWA),對於未知的命題不知道正確與否,所以 \(bern\) 通過局部推測整體,一對多關系在整個知識體系中也更可能是一對多關系,增大替換頭實體的概率確實更易得到 negateive,因此 \(bern\) 策略是有意義的。
實驗
訓練同樣采用 SGD,進行了鏈接預測、三元組分類和關系抽取三項實驗。

代碼
還是 \(Pykg2vec\) 實現的代碼:
class TransH(PairwiseModel):
def __init__(self, **kwargs):
super(TransH, self).__init__(self.__class__.__name__.lower())
param_list = ["tot_entity", "tot_relation", "hidden_size", "l1_flag"]
param_dict = self.load_params(param_list, kwargs)
self.__dict__.update(param_dict)
self.ent_embeddings = NamedEmbedding("ent_embedding", self.tot_entity, self.hidden_size)
self.rel_embeddings = NamedEmbedding("rel_embedding", self.tot_relation, self.hidden_size)
self.w = NamedEmbedding("w", self.tot_relation, self.hidden_size)
nn.init.xavier_uniform_(self.ent_embeddings.weight)
nn.init.xavier_uniform_(self.rel_embeddings.weight)
nn.init.xavier_uniform_(self.w.weight)
self.parameter_list = [
self.ent_embeddings,
self.rel_embeddings,
self.w,
]
self.loss = Criterion.pairwise_hinge
def forward(self, h, r, t):
h_e, r_e, t_e = self.embed(h, r, t)
norm_h_e = F.normalize(h_e, p=2, dim=-1)
norm_r_e = F.normalize(r_e, p=2, dim=-1)
norm_t_e = F.normalize(t_e, p=2, dim=-1)
if self.l1_flag:
return torch.norm(norm_h_e + norm_r_e - norm_t_e, p=1, dim=-1)
return torch.norm(norm_h_e + norm_r_e - norm_t_e, p=2, dim=-1)
def embed(self, h, r, t):
"""Function to get the embedding value.
Args:
h (Tensor): Head entities ids.
r (Tensor): Relation ids of the triple.
t (Tensor): Tail entity ids of the triple.
Returns:
Tensors: Returns head, relation and tail embedding Tensors.
"""
emb_h = self.ent_embeddings(h)
emb_r = self.rel_embeddings(r)
emb_t = self.ent_embeddings(t)
proj_vec = self.w(r)
emb_h = self._projection(emb_h, proj_vec)
emb_t = self._projection(emb_t, proj_vec)
return emb_h, emb_r, emb_t
@staticmethod
def _projection(emb_e, proj_vec):
"""Calculates the projection of entities"""
proj_vec = F.normalize(proj_vec, p=2, dim=-1)
# [b, k], [b, k]
return emb_e - torch.sum(emb_e * proj_vec, dim=-1, keepdims=True) * proj_vec
TransH 解決了 TransE 難以表示自反/一對多/多對一/多對多的復雜關系類型的問題,還是非常有效的。
TransR
paper: Learning Entity and Relation Embeddings for Knowledge Graph Completion
論文
空間投影
TransR 是清華大學劉知遠、孫茂松老師團隊提出來的,發表在 2015 年的 AAAI 上。創新點是將 TransH 的投影到超平面更進一步——投影到空間,本質是將投影向量換為投影矩陣,實體還是用一個向量表示,關系用一個向量和一個矩陣表示。效果提升並不大,但計算量顯著增大。TransR 的 R 代表 relation space。

每個關系有自己一個獨立的空間,如果要計算三元組 \((h,r,t)\) 的得分,首先要將 \(h\) 和 \(t\) 投影到 \(r\) 所在的空間中來。


將投影操作的頭尾實體帶入到打分函數中,會發現似曾相識,沒錯,SE 模型的打分函數就跟它很像,不過 SE 長這樣:,它的每個關系 \(r\) 對應兩個矩陣 \(M_{r,1}\) 和 \(M_{r,2}\),沒有對於 \(r\) 自身的 embedding 向量,而 TransR 有一個關系本身的嵌入 \(r\) 以及一個用於空間投影的矩陣 \(M_r\)。
Loss 都是一樣的,訓練也是采用 SGD。
文章還提出了 CTransR (Cluster-based TransR),對每個關系下的頭尾實體進行聚類,並為每一個類別分配一個向量來表示,以挖掘細粒度的關系語義。
實驗
和 TransH 一樣,TransR 也進行了鏈接預測、三元組分類和關系抽取三項實驗。


可以看到,無論是整體的鏈接預測,還是不同類型關系下的鏈接預測,TransR 及其變體都達到了最優,但這是以巨大的計算量復雜度換來的。根據原文的說法,TransR 的訓練時間約是 TransE 的 36 倍、是 TransH 的 6 倍。我自己在訓練 TransR 的過程中也明顯感覺到比其他模型更為耗時。
代碼
直接上 \(Pykg2vec\) 的代碼:
class TransR(PairwiseModel):
def __init__(self, **kwargs):
super(TransR, self).__init__(self.__class__.__name__.lower())
param_list = ["tot_entity", "tot_relation", "rel_hidden_size", "ent_hidden_size", "l1_flag"]
param_dict = self.load_params(param_list, kwargs)
self.__dict__.update(param_dict)
self.ent_embeddings = NamedEmbedding("ent_embedding", self.tot_entity, self.ent_hidden_size)
self.rel_embeddings = NamedEmbedding("rel_embedding", self.tot_relation, self.rel_hidden_size)
self.rel_matrix = NamedEmbedding("rel_matrix", self.tot_relation, self.ent_hidden_size * self.rel_hidden_size)
nn.init.xavier_uniform_(self.ent_embeddings.weight)
nn.init.xavier_uniform_(self.rel_embeddings.weight)
nn.init.xavier_uniform_(self.rel_matrix.weight)
self.parameter_list = [
self.ent_embeddings,
self.rel_embeddings,
self.rel_matrix,
]
self.loss = Criterion.pairwise_hinge
def transform(self, e, matrix):
matrix = matrix.view(-1, self.ent_hidden_size, self.rel_hidden_size)
if e.shape[0] != matrix.shape[0]:
e = e.view(-1, matrix.shape[0], self.ent_hidden_size).permute(1, 0, 2)
e = torch.matmul(e, matrix).permute(1, 0, 2)
else:
e = e.view(-1, 1, self.ent_hidden_size)
e = torch.matmul(e, matrix)
return e.view(-1, self.rel_hidden_size)
def embed(self, h, r, t):
"""Function to get the embedding value.
Args:
h (Tensor): Head entities ids.
r (Tensor): Relation ids of the triple.
t (Tensor): Tail entity ids of the triple.
Returns:
Tensors: Returns head, relation and tail embedding Tensors.
"""
h_e = self.ent_embeddings(h)
r_e = self.rel_embeddings(r)
t_e = self.ent_embeddings(t)
h_e = F.normalize(h_e, p=2, dim=-1)
r_e = F.normalize(r_e, p=2, dim=-1)
t_e = F.normalize(t_e, p=2, dim=-1)
h_e = torch.unsqueeze(h_e, 1)
t_e = torch.unsqueeze(t_e, 1)
# [b, 1, k]
matrix = self.rel_matrix(r)
# [b, k, d]
transform_h_e = self.transform(h_e, matrix)
transform_t_e = self.transform(t_e, matrix)
# [b, 1, d] = [b, 1, k] * [b, k, d]
h_e = torch.squeeze(transform_h_e, axis=1)
t_e = torch.squeeze(transform_t_e, axis=1)
# [b, d]
return h_e, r_e, t_e
def forward(self, h, r, t):
"""Function to get the embedding value.
Args:
h (Tensor): Head entities ids.
r (Tensor): Relation ids.
t (Tensor): Tail entity ids.
Returns:
Tensors: the scores of evaluationReturns head, relation and tail embedding Tensors.
"""
h_e, r_e, t_e = self.embed(h, r, t)
norm_h_e = F.normalize(h_e, p=2, dim=-1)
norm_r_e = F.normalize(r_e, p=2, dim=-1)
norm_t_e = F.normalize(t_e, p=2, dim=-1)
if self.l1_flag:
return torch.norm(norm_h_e + norm_r_e - norm_t_e, p=1, dim=-1)
return torch.norm(norm_h_e + norm_r_e - norm_t_e, p=2, dim=-1)
個人感覺就這些模型本身並沒有什么高明的地方,無非是改了一下打分函數的樣式(KGE 最核心的部分就是打分函數),只是因為給這些打分函數賦予了一些物理意義去解釋(投影操作之類),使得其看起來 plausible,這些解釋撐起了一篇又一篇的論文。讓我想起那句話:知識本身並不難,其本身是一個個定理並不難,只是因為被包裝了,所以才看起來難。
下篇更新 TransD、TransA、TranSparse,敬請期待!_