LTN中的非逻辑符号、联结词与量词
上一篇的内容对 LTN 中的非逻辑符号、连接词、量词进行了简单的引入,这一篇我们对 LTN 中的这些基本组成部分,结合简单的示例和代码进行讲解。本文会将思考和答案融入讲解中,对应的问题穿插在文中,供大家边阅读边思考。
我们首先导入相应的包:
import ltn
import torch
import numpy as np
device = torch.device('cuda:0' if torch.cuda.is_available() else "cpu")
1. 🔣 非逻辑符号
非逻辑符号包括常量、变量、谓词以及函数这四种(可以类比编程语言中的数据类型)。
1.1 常量
LTN 中的常量是通过实数张量进行 grounding 的。每个常量 都被映射到:
中。
论域中的对象可以是任何阶的张量。例如,0 阶张量对应一个标量,1 阶张量对应一个向量,2 阶张量对应一个矩阵,以此类推。
例如,我们定义:
和
c1 = ltn.Constant(torch.tensor([2.1, 3]))
c2 = ltn.Constant(torch.tensor([[4.2, 3, 2.5], [4, -1.3, 1.8]]))
c3 = ltn.Constant(torch.tensor([0.,0.]), trainable=True)
看完代码,感觉就是一个单纯的赋值,那么我们来看看 c1 的完整 Grounding 过程:
-
第一步:定义抽象逻辑符号 先在逻辑系统里定义一个抽象的符号,比如叫
c1,这个符号本身没有任何数值,只是一个“占位符名字”,代表“论域里的某个个体”。 -
第二步:建立符号到张量的映射 定义接地函数 (LTN 库底层实现),把这个抽象符号
c1映射到具体的实数张量[2.1, 3]上,也就是:
这两步合起来,就是“从抽象符号空间到具体张量空间的映射”,也就是 Grounding。
代码中,ltn.Constant 是一个接口,只是给原始 PyTorch 张量套了一层“逻辑身份的壳”,壳里存的张量本身没有任何变化,但套壳后,它就能参与 LTN 的自动逻辑计算了,这个张量就代表了逻辑论域里的一个具体个体,而原始张量做不到。注意,Constant 构造函数需要一个包含个体特征的 PyTorch 张量。
在底层实现中,LTN 常量是通过 LTNObject 对象来实现的。这些特定的对象封装了一个值(个体)和一个重要的属性 free_vars。如果 LTN 常量的 trainable 参数被设置为 True,那么该 LTN 常量中包含的 PyTorch 张量的 requires_grad 参数将被设置为 True。
LTN 常量的值可以通过 value 属性轻松访问。
print(c1.value)
print(c3.value)
# 使用 detach() 将张量从 PyTorch 的计算图中分离出来,切断梯度传播链路,
# 以便安全地进行打印、转 numpy 数组、可视化等「非训练操作」
print(c3.value.detach().cpu().numpy())
tensor([2.1000, 3.0000], device='cuda:0')
tensor([0., 0.], device='cuda:0', requires_grad=True)
[0. 0.]
❓思考题
- Q1:使用
ltn.Constant对 PyTorch 张量进行 grounding 后,得到的对象与原始的 PyTorch 张量有什么区别? - Q2:文中提到“LTN 中的常量是通过实数张量进行 grounding 的”,但从代码
c1 = ltn.Constant(torch.tensor([2.1, 3]))来看,似乎只是用张量给常量赋值,该如何理解这里的 “grounding”?
1.2 谓词
LTN 中的谓词是从某个 元输入值空间映射到 区间的函数。描述的是“ 个个体之间的关系”程度。在 LTN 中,谓词可以是神经网络或任何实现此映射的函数。
在 LTN 中构造谓词有不同的方法。构造函数 ltn.Predicate(model, func) 提供了两种构造方式:
- 如果
model参数不为None,则通过使用作为输入提供的torch.nn.Module模型实例来构造谓词; - 如果
model参数为None且func参数不为None,则通过使用输入的函数来构造谓词。使用函数来构造谓词通常用于没有权重跟踪的小型数学操作(不可训练的函数)。
以下定义了一个使用 func 参数的谓词 和一个使用 model 参数的谓词 。
mu = ltn.Constant(torch.tensor([2., 3.]))
P1 = ltn.Predicate(func=lambda x: torch.exp(-torch.norm(x - mu.value, dim=1)))
class ModelP2(torch.nn.Module):
"""For more info on how to use torch.nn.Module:
https://pytorch.org/docs/stable/generated/torch.nn.Module.html"""
def __init__(self):
super(ModelP2, self).__init__()
self.elu = torch.nn.ELU()
self.sigmoid = torch.nn.Sigmoid()
self.dense1 = torch.nn.Linear(2, 5)
self.dense2 = torch.nn.Linear(5, 1) # returns one value in [0,1]
def forward(self, x):
x = self.elu(self.dense1(x))
return self.sigmoid(self.dense2(x))
modelP2 = ModelP2().to(device)
P2 = ltn.Predicate(model=modelP2)
在谓词 的定义中,使用的是 func 参数,需要指定一个手工函数。
假设我们需要 表示 “ 属于以 为中心的某个概念/类别”的隶属度,可以指定一个径向基函数:
func=lambda x: torch.exp(-torch.norm(x - mu.value, dim=1))
- 当 接近 时,,则 (真值高)
- 当 远离 时,,则 (真值低)
那么,此时 是一个径向基函数(RBF)形式的谓词:
或更详细地表示为:
在 中,访问了常量 mu 的 value 属性,因为 x 接受的是一个 PyTorch 张量(输入的如果是 LTNObject,会自动提取对应的 value),而 mu 是 LTN 常量。他们之间的操作符 - 对这两种不同类型不支持。
每当在定义谓词时,若涉及 LTN 对象(常量或变量),都需要访问其值。
c1 = ltn.Constant(torch.tensor([2.1, 3]))
c2 = ltn.Constant(torch.tensor([4.5, 0.8]))
c3 = ltn.Constant(torch.tensor([3.0, 4.8]).to(device))
print(P1(c1).value)
print(P1(c2).value)
print(P2(c3).value)
print(P1(c1))
tensor(0.9048, device='cuda:0')
tensor(0.0358, device='cuda:0')
tensor(0.3731, device='cuda:0', grad_fn=<ViewBackward>)
LTNObject(value=tensor(0.9048, device='cuda:0'), free_vars=[])
LTN 谓词返回的是 LTNObject 实例。访问 LTNObject 的 value 属性,可以获得谓词的实际值。
上面展示了一个一元谓词。如果一个 LTN 谓词(或 LTN 函数)需要多个输入,例如 ,则参数必须用逗号分隔。此时,LTN 在内部将输入进行转换,以便第一个维度上对应一个“批量”维度。因此,大多数操作应该适用于 dim=1(特征的维度)。
在这里,我们展示了一个 LTN 中二元谓词的实现。
class ModelP4(torch.nn.Module):
def __init__(self):
super(ModelP4, self).__init__()
self.elu = torch.nn.ELU()
self.sigmoid = torch.nn.Sigmoid()
self.dense1 = torch.nn.Linear(4, 5)
self.dense2 = torch.nn.Linear(5, 1) # returns one value in [0,1]
def forward(self, x, y):
x = torch.cat([x, y], dim=1)
x = self.elu(self.dense1(x))
return self.sigmoid(self.dense2(x))
P4 = ltn.Predicate(ModelP4().to(device))
c1 = ltn.Constant(torch.tensor([2.1, 3]).to(device))
c2 = ltn.Constant(torch.tensor([4.5, 0.8]).to(device))
print(P4(c1, c2).value) # multiple arguments are passed as a list
tensor(0.8279, device='cuda:0', grad_fn=<ViewBackward>)
: 个输入论域构成的笛卡尔积空间,不等同于“任何阶的张量”,它的核心含义是“ 个输入论域的笛卡尔积空间”——
而这个空间里的每个输入个体,才可以是任意阶的实数张量——
1.3 函数
LTN 中的函数是任何将 个个体(同 元输入值空间)映射到一个张量域中的个体的数学函数。输入 个个体,输出任意张量域的新个体,用来构造新的逻辑项(生成“新的事物/特征”)。
在 LTN 中构造函数有不同的方法。构造函数 ltn.Function(model, func) 提供了两种构造方式:
- 如果
model参数不为None,则通过使用作为输入提供的torch.nn.Module模型实例来构造函数; - 如果
model参数为None且func参数不为None,则通过使用输入的函数来构造函数。使用函数来构造函数通常用于没有权重跟踪的小型数学操作(不可训练的函数)。
以下定义了一个使用 func 参数的函数 和一个使用 model 参数的函数 。
f1 = ltn.Function(func=lambda x, y: x - y)
class MyModel(torch.nn.Module):
def __init__(self):
super(MyModel, self).__init__()
self.dense1 = torch.nn.Linear(2, 10)
self.dense2 = torch.nn.Linear(10, 5)
self.relu = torch.nn.ReLU()
def forward(self, x):
x = self.relu(self.dense1(x))
return self.dense2(x)
model_f2 = MyModel().to(device)
f2 = ltn.Function(model=model_f2)
LTN 函数返回的是 LTNObject 实例。访问 LTNObject 的 value 属性,可以获得函数返回的实际值。
c1 = ltn.Constant(torch.tensor([2.1, 3]).to(device))
c2 = ltn.Constant(torch.tensor([4.5, 0.8]).to(device))
print(f1(c1, c2).value) # multiple arguments are passed as a list
print(f2(c1).value)
print(f2(c1))
将函数 ltn.Function 与谓词 ltn.Predicate 进行对比,我们发现它们的构造参数都是 (model, func),但是它们具体指定的函数/模型,代表的逻辑含义是不同的,如下表所示:
| 维度 | LTN 谓词 | LTN 函数 |
|---|---|---|
| 一阶逻辑对应 | 原子公式(Atomic Formula) | 项(Term) |
| 核心作用 | 描述“ 个个体的性质/关系的满足程度” | 描述“从 个个体到 1 个新个体的映射/变换” |
| 数学表达 | ||
| 代码对应 | P1(x):x 和 mu 相似的真度;P4(x,y):x 和 y 满足目标关系的真度 | f1(x,y):x 减 y 得到的新向量;f2(x):x 经过 MLP 变换得到的新特征向量 |
LTN 函数和谓词的核心差别,完全对应一阶逻辑里“项(Term)”和“原子公式(Atomic Formula)”的差别:
- 谓词:是“打分器”,输入 个个体,输出 区间的真度,用来构成逻辑命题(判断“是不是/满不满足”);
- 函数:是“变换器”,输入 个个体,输出任意张量域的新个体,用来构造新的逻辑项(生成“新的事物/特征”)。
❓思考题
- Q3:LTN 的函数和谓词的核心区别是什么?
1.4 变量
LTN 中的变量是来自某个域的个体/常量的序列。在逻辑中,变量对于编写带量词的语句非常有用,例如 。
相较于常量代表论域里的单个物体来说,变量则代表了论域里的一批个体的占位符/代表。同时,需要注意,变量是一个序列而不是集合,也就是说,序列中可以包含相同的值多次。
这里,要强调一下,普通编程语言(Python/Java/C++)里的变量/常量和 LTN 里的变量/常量的区别:
普通编程语言的变量/常量对应计算机科学中的“标识符”,是数据的别名,用于简化对内存地址的操作,在数据形态上,可存储对应编程语言中任意类型的数据,无固定维度语义。
LTN 里的变量与常量对应一阶逻辑学定义,和“存数据”无关,其对应的数据类型是 torch.Tensor。
- LTN 常量(Constant) = 逻辑世界里的单个具体实体,永远代表某一个东西(一个样本、一个原型点、一个物体)
- LTN 变量(Variable) = 逻辑世界里的一批实体的占位符,永远代表 个东西(所有样本、一组数据),专门给量词 (所有) / (存在)使用,数值不修改、不优化
以下定义了两个变量 和 ,分别从 的正态分布中抽取了 10 和 5 个个体。在 LTN 中,变量需要被 free_vars 属性标记(请参见下面的 'x' 和 'y' 参数)。这些标签是 LTN 内部使用的,用于识别变量并实现许多其他重要功能,例如逻辑状态的动态变化。
x = ltn.Variable('x', torch.randn((10, 2)).to(device))
y = ltn.Variable('y', torch.randn((5, 2)).to(device))
构造函数在内部为变量构建了一个 LTNObject。其值将是给定的张量,而 free_vars 属性将分别是列表 ['x'](第一个变量)和 ['y'](第二个变量)。free_vars 属性包含了 LTNObject 中所有自由变量的标签。在逻辑中,当一个变量没有被量词量化时(例如存在量词或全称量词),我们称其为自由变量。而量词只能作用于自由变量,把它从自由状态变成约束状态。
对于常量,free_vars 属性是空列表,因为常量不需要被约束,它本身就是固定的、确定的个体,变量才需要被约束。
对一个变量 个个体的术语/谓词进行评估(逐一代入计算),结果会得到 个输出值,其中第 个输出值对应于使用第 个个体计算的术语。
类似地,对 个变量 进行评估(逐一代入计算),每个变量分别有 个个体,结果将是一个包含 个值的张量。该结果组织成一个张量,其中前 个维度可以索引以检索每个变量对应的结果。这个张量被封装在一个 LTNObject 中,free_vars 属性告知哪个维度对应哪个变量(使用变量的标签)。
# 注意:输出结果是一个二维张量,其中每个元素
# 都表示将变量 x 中的每个个体与变量 y 中的每个个体逐一代入谓词 P4 后,计算得到的满足程度(真值)。
P4 = ltn.Predicate(ModelP4().to(device))
res1 = P4(x, y)
print(res1.shape())
print(res1.free_vars) # 动态添加的属性;表明张量第0轴对应变量x,第1轴对应变量y
print(res1.value[2, 0]) # 该结果由x中的第3个个体与y中的第1个个体计算得出
print(res1.value)
torch.Size([10, 5])
['x', 'y']
tensor(0.4576, device='cuda:0', grad_fn=<SelectBackward>)
tensor([[0.4849, 0.4162, 0.4373, 0.3580, 0.3211],
[0.4382, 0.3093, 0.3412, 0.2596, 0.2292],
[0.4576, 0.3392, 0.3724, 0.2866, 0.2542],
[0.4809, 0.4021, 0.4283, 0.3443, 0.3082],
[0.4915, 0.4377, 0.4517, 0.3839, 0.3457],
[0.4717, 0.3749, 0.4053, 0.3174, 0.2819],
[0.4612, 0.3487, 0.3814, 0.2946, 0.2616],
[0.4537, 0.3318, 0.3647, 0.2799, 0.2480],
[0.4717, 0.3712, 0.4051, 0.3160, 0.2816],
[0.4381, 0.3090, 0.3408, 0.2592, 0.2289]], device='cuda:0',
grad_fn=<ViewBackward>)
的结果具有形状 ,因为 有 10 个个体, 有 5 个个体。 结果的第一个单元格是 对 的第一个个体和 的第一个个体的评估,第二个单元格是 对 的第一个个体和 的第二个个体的评估,以此类推。
注意,在表示 评估结果的 LTNObject 上调用了 shape() 方法。这个方法是 res1.value.shape 的简写。
现在,我们看到相同的示例,但应用的是 LTN 函数,而不是 LTN 谓词。
# 注意:最后一个维度对应输出结果的特征维度;
# 本例中,函数f1将结果映射到二维实数空间,因此输出结果新增了一个维度为2的轴
# 输出张量的形状为 (10, 5, 2)
res2 = f1(x, y)
print(res2.shape())
print(res2.free_vars)
print(res2.value[2,0]) # 该结果由x中的第3个个体与y中的第1个个体计算得出
print(res2.value)
torch.Size([10, 5, 2])
['x', 'y']
tensor([-1.4045, 2.5508], device='cuda:0')
tensor([[[-0.2344, 0.2695],
[ 2.1361, -1.0484],
[ 0.5127, -1.8646],
[ 2.7547, -1.9401],
[ 3.3089, -2.3892]],
[[-2.7586, 1.6886],
[-0.3881, 0.3707],
[-2.0115, -0.4455],
[ 0.2306, -0.5210],
[ 0.7848, -0.9701]],
[[-1.4045, 2.5508],
[ 0.9660, 1.2329],
[-0.6574, 0.4167],
[ 1.5846, 0.3412],
[ 2.1388, -0.1079]],
[[-0.5178, 0.5359],
[ 1.8527, -0.7820],
[ 0.2293, -1.5982],
[ 2.4713, -1.6737],
[ 3.0255, -2.1229]],
[[ 0.4628, 0.1285],
[ 2.8333, -1.1894],
[ 1.2099, -2.0055],
[ 3.4519, -2.0811],
[ 4.0062, -2.5302]],
[[-2.2335, -1.1059],
[ 0.1370, -2.4238],
[-1.4864, -3.2399],
[ 0.7556, -3.3155],
[ 1.3098, -3.7646]],
[[-2.1438, 0.4441],
[ 0.2267, -0.8738],
[-1.3967, -1.6900],
[ 0.8454, -1.7655],
[ 1.3996, -2.2146]],
[[-1.6024, 2.6141],
[ 0.7681, 1.2961],
[-0.8553, 0.4800],
[ 1.3867, 0.4045],
[ 1.9409, -0.0447]],
[[-0.5928, 2.2357],
[ 1.7777, 0.9178],
[ 0.1543, 0.1017],
[ 2.3963, 0.0262],
[ 2.9506, -0.4230]],
[[-2.5531, 2.1435],
[-0.1826, 0.8256],
[-1.8060, 0.0095],
[ 0.4360, -0.0661],
[ 0.9902, -0.5152]]], device='cuda:0')
函数输出不再是 区间内的值,因为我们现在应用的是一个函数。还要注意形状的变化。在谓词的情况下,结果的形状是 ,因为结果包含了 个真值,取值范围在 之间。现在,函数不再返回一个标量,而是返回一个在 中的实数向量,对应了新形状 的最后一个维度。
在这个最后的示例中,我们对一个变量和一个常量应用了一个谓词。现在 free_vars 属性只包含 ,因为只有变量 被作为输入提供。
c1 = ltn.Constant(torch.tensor([2.1, 3]))
res3 = P4(c1, y)
print(res3.shape()) # output has shape (5,)
print(res3.free_vars)
print(res3.value[0]) # gives the result calculated with c1 and the 1st individual in y
print(res3.value)
torch.Size([5])
['y']
tensor(0.4776, device='cuda:0', grad_fn=<SelectBackward>)
tensor([0.4776, 0.3744, 0.4087, 0.3190, 0.2844], device='cuda:0',
grad_fn=<ViewBackward>)
1.4.1 由可学习常量构成的变量
在 LTN 中,可以定义变量,其个体是可学习的常量,但是给出的仅仅是常量的值,而不是常量本身。这在嵌入学习任务中非常有用,其中一个变量的个体可能是需要学习的嵌入向量。
在这个示例中,声明了两个可学习常量。然后,使用这些常量构建一个 LTN 变量(LTN 变量只接受 PyTorch 张量作为值)。变量的第一个个体将是第一个常量,而第二个个体将是第二个常量。
在 对 进行评估之后, 的两个个体都会有一个 grad_fn 属性。这意味着梯度已经通过谓词传递给了可学习的常量。两个常量的 grad_fn 依旧是 None,因此常量在应用谓词之后将保持不变。具体而言,我们将常量的值提供给了变量。
c1 = ltn.Constant(torch.tensor([2.1, 3]), trainable=True)
c2 = ltn.Constant(torch.tensor([4.5, 0.8]), trainable=True)
# PyTorch will keep track of the gradients between c1, c2 and x.
# Read tutorial 3 for more details.
x = ltn.Variable('x', torch.stack([c1.value, c2.value]))
res = P2(x)
print(res.value)
print(x.value[0])
print(x.value[1])
print(c1.value.grad_fn)
print(c2.value.grad_fn)
tensor([0.3802, 0.5987], device='cuda:0', grad_fn=<ViewBackward>)
tensor([2.1000, 3.0000], device='cuda:0', grad_fn=<SelectBackward>)
tensor([4.5000, 0.8000], device='cuda:0', grad_fn=<SelectBackward>)
None
None
❓思考题
- Q4:LTN 变量和常量的核心区别是什么?
- Q5:
trainable=True的常量就是变量吗? - Q6:
free_vars属性到底有什么用? - Q7:为什么量词语句必须用变量,常量不行?
- Q8:为什么常量的
free_vars属性是空列表?常量不能被约束吗?
2. 🔗 联结词
LTN 支持各种逻辑联结词。它们是通过模糊语义来构建的。一般来说,如下四个模糊语义,常用来构建 LTN 的四个基本联结词——否定()、合取()、析取()和蕴涵():
- 标准的否定:
- 乘积 t-范数:
- 乘积 t-余范数(概率和):
- 赖辛巴赫蕴涵:
其中 和 表示在 区间内的两个真值。模糊语义的基础就是“真度”,联结词是用来组合“真的程度”的。
在 LTN 中,创建一个联结词非常简单。可以使用构造函数 Connective(),它接受一个一元或二元的模糊联结词语义。可以从 ltn.fuzzy_ops 模块中选择语义,在 ltn.fuzzy_ops 模块中,封装了一些使用 PyTorch 实现的模糊语义函数。
基于上述建议的联结词模糊语义构建的联结词:
Not = ltn.Connective(ltn.fuzzy_ops.NotStandard())
And = ltn.Connective(ltn.fuzzy_ops.AndProd())
Or = ltn.Connective(ltn.fuzzy_ops.OrProbSum())
Implies = ltn.Connective(ltn.fuzzy_ops.ImpliesReichenbach())
特别地,包装器 ltn.Connective 允许在 LTN 公式中使用这些运算符。具体而言,它负责组合具有不同变量的子公式(这些子公式可能具有不同的维度,需要进行“广播”才能应用联结词)。
在这个例子中,我们创建了两个具有不同个体数的变量,两个常量,以及一个度量二维实数空间 中两点相似度的谓词。
x = ltn.Variable('x', torch.randn((10, 2))) # 10 values in R²
y = ltn.Variable('y', torch.randn((5, 2))) # 5 values in R²
c1 = ltn.Constant(torch.tensor([0.5, 0.0]))
c2 = ltn.Constant(torch.tensor([4.0, 2.0]))
Eq = ltn.Predicate(func=lambda x, y: torch.exp(-torch.norm(x - y, dim=1))) # predicate measuring similarity
Eq(c1, c2).value
通过如下代码,可以查看联结词的行为是否符合四个模糊语义的公式定义:
Not(Eq(c1, c2)).value
tensor(0.9822)
Implies(Eq(c1, c2), Eq(c2, c1)).value
tensor(0.9825)
# 注意结果的维度:结果是针对每个 x 进行评估的。
And(Eq(x, c1), Eq(x, c2)).shape()
torch.Size([10])
Eq(x, c1)
LTNObject(value=tensor([0.5992, 0.1761, 0.2725, 0.4080, 0.1567, 0.5702, 0.1399, 0.0352, 0.0860,
0.1423], device='cuda:0'), free_vars=['x'])
Eq(x, c2).shape()
torch.Size([10])
# 注意结果的维度:结果是针对每个 x 和 y 进行评估的。
# 还要注意,y 没有出现在 `Or` 的第一个参数中;
# 联结词会将其两个参数的结果广播以匹配。
Or(Eq(x, c1), Eq(x, y)).shape()
torch.Size([10, 5])
注意最后两行代码中打印的形状。在第一行中,由于公式中只出现了变量 ,因此形状为 10。公式已经对每个 进行了评估。
在第二行中,由于公式中同时出现了 和 ,因此形状为 。公式已经对 和 的每个组合进行了评估。
LTN 联结词返回的是 LTNObject 实例,访问联结词评估结果可以通过 value 属性或 shape() 方法,就像谓词和函数一样。
❓思考题
- Q9:什么是模糊语义?
- Q10:联结词只能接受真值吗?
3. 📐 量词
LTN 支持全称量化和存在量化。它们是通过聚合运算符进行构建的。如下两个聚合运算公式常用于构建存在量化和全称量化:
-
存在量化(“exists”):
-
全称量化(“for all”):
其中 是在 区间内的真值列表。
在 LTN 中,创建量词非常简单。可以使用构造函数 Quantifier(),它接受一个聚合语义(ltn.fuzzy_ops.)和一个字符(quantifier=),表示与量词关联的量化类型("e" 代表存在量化,"f" 代表全称量化)。
在这个例子中,我们使用上述模糊语义创建量词。
Forall = ltn.Quantifier(ltn.fuzzy_ops.AggregPMeanError(p=2), quantifier="f")
Exists = ltn.Quantifier(ltn.fuzzy_ops.AggregPMean(p=2), quantifier="e")
包装器 ltn.Quantifier 允许在 LTN 公式中使用聚合器。它负责选择要聚合的张量(公式)维度,具体取决于作为参数传入的变量。
在这个例子中,我们创建了与之前联结词示例类似的变量和谓词。
x = ltn.Variable('x', torch.randn((10, 2))) # 10 values in R²
y = ltn.Variable('y', torch.randn((5, 2))) # 5 values in R²
Eq = ltn.Predicate(func=lambda x, y: torch.exp(-torch.norm(x - y, dim=1))) # predicate measuring similarity
Eq(x, y).shape()
现在,我们将一些量词应用于公式,并观察它们如何影响输出及其形状。
在第一个案例中,我们对 x 进行了量化,因此输出的形状为 5。这意味着我们已经移除了与 x 相关的维度,只留下了与 y 相关的维度。形状为 5,因为 y 有 5 个个体。
Forall(x, Eq(x, y)).shape()
torch.Size([5])
量化的本质是“维度聚合”:全称量词 会把“被量化变量对应的维度”做模糊聚合(比如取均值、PMean、min 等,LTN 默认是模糊逻辑的聚合操作),从而移除该维度。
针对 Eq(x,y) 的 矩阵:
- 被量化的变量是
x(对应矩阵的第 0 维,即 10 行) - 量化操作会对每一列(每个
y个体)聚合其对应的 10 个x个体的相似性值 - 聚合后, 的矩阵会被压缩为长度为 5 的一维向量
下面三个案例中,输出是一个标量,因为量化已对两个变量进行。这意味着聚合已经在两个维度上执行,即 x 维度和 y 维度。
Forall([x, y], Eq(x, y)).value
tensor(0.3004)
Exists([x, y], Eq(x, y)).value
tensor(0.4113)
Forall(x, Exists(y, Eq(x, y))).value
tensor(0.3635)
LTN 量词返回的是 LTNObject 实例,访问量词评估结果可以通过 value 属性或 shape() 方法,就像谓词和函数一样。当量化仅对一个变量进行时,可以直接将该变量传递给量词;但如果量化需要对多个变量进行,则需要通过列表将变量传递给量词。
3.1 量词的语义
pMean 语义可以理解为一个软最大值,它依赖于超参数 :
- :操作符趋向于
mean(均值) - :操作符趋向于
max(最大值)
类似地,pMeanError 语义可以理解为一个软最小值:
- :操作符趋向于
mean(均值) - :操作符趋向于
min(最小值)
提供了灵活性,可以根据应用场景调整公式的严格性,以适应数据中的离群值。不同的 选择在训练过程中可能会有强烈的影响。可以在初始化运算符时设置 的默认值,或者在每次调用运算符时使用不同的值。
如下代码使用了具有不同 参数值的量词。一般来说, 越大,存在量化越容易满足,而全称量化越难满足。
Forall(x, Eq(x, c1), p=2).value
tensor(0.4100)
Forall(x, Eq(x, c1), p=10).value
tensor(0.2308)
Exists(x, Eq(x, c1), p=2).value
tensor(0.4980)
Exists(x, Eq(x, c1), p=10).value
tensor(0.6306)
3.2 对角量化
给定 2 个(或更多)变量,有些场景下我们希望仅对特定的对(或元组)表达语句,使得第 个元组包含变量的第 个实例。例如,确保只在“正确的样本-标签对”上学习。
换句话说,在某些情况下,我们不希望在所有可能的变量个体组合上评估公式。
我们使用 ltn.diag(对角量化)来实现这一点。
注意:对角量化假设变量具有相同数量的个体。因为需要在量化中涉及的变量的个体之间建立一对一的对应关系。
在简化的伪代码中,常规量化会计算:
for x_i in x:
for y_j in y:
results.append(P(x_i,y_j))
aggregate(results)
相比之下,对角量化会计算:
for x_i, y_i in zip(x,y):
results.append(P(x_i,y_i))
aggregate(results)
我们在以下设置中展示 ltn.diag:
- 变量 表示 中的 100 个个体
- 变量 表示 中的 100 个 one-hot 标签(3 个可能的类别)
- 根据 进行构建,使得每一对 ,对于 表示数据集中的一个正确示例
- 分类器 给出样本 对应标签 的置信度值,范围在 之间
# The values are generated at random, for the sake of illustration.
# In a real scenario, they would come from a dataset.
samples = torch.randn((100, 2, 2)) # 100 R^{2x2} values
labels = torch.randint(0, 3, size=(100,)) # 100 labels (class 0/1/2) that correspond to each sample
onehot_labels = torch.nn.functional.one_hot(labels, num_classes=3)
x = ltn.Variable("x", samples)
l = ltn.Variable("l", onehot_labels)
class ModelC(torch.nn.Module):
def __init__(self):
super(ModelC, self).__init__()
self.elu = torch.nn.ELU()
self.softmax = torch.nn.Softmax(dim=1)
self.dense1 = torch.nn.Linear(4, 5)
self.dense2 = torch.nn.Linear(5, 3)
def forward(self, x, l):
x = torch.flatten(x, start_dim=1)
x = self.elu(self.dense1(x))
x = self.softmax(self.dense2(x))
return torch.sum(x * l, dim=1)
C = ltn.Predicate(ModelC())
print(C(x, l).shape()) # Computes the 100x100 combinations
torch.Size([100, 100])
ltn.diag(x, l) # sets the diag behavior for x and l
print(C(x, l).shape())# Computes the 100 zipped combinations
torch.Size([100])
print(x.free_vars)
['diag_x_l']
print(l.free_vars)
['diag_x_l']
ltn.undiag(x, l) # resets the normal behavior
print(C(x, l).shape()) # Computes the 100x100 combinations
torch.Size([100, 100])
如果一些变量通过 ltn.diag 被标记,LTN 将只计算它们的“压缩”结果(而不是通常的“广播”)。换句话说,公式将只在特定的个体元组上进行评估,而不是在变量的所有可能个体组合上进行评估。
可以观察到,第一个评估的形状是 。之所以这样,是因为 LTN 生成了 和 的所有可能个体组合,然后应用谓词。注意在应用 ltn.diag 后形状是如何变化的。这是因为两个变量的个体被按一对一的方式进行对应,且谓词仅在这些特定的个体元组上进行计算。
在设置对角量化后,变量的 free_vars 属性是如何变化的。可以通过检查变量的标签是否以 "diag_" 开头来识别变量是否处于对角量化设置中。特别地,处于同一对角量化设置中的所有变量将共享相同的标签。
可以使用 ltn.undiag 来移除变量的对角量化设置,并恢复变量的常规 LTN 广播行为。这一点从最后的打印中得到了明确。实际上,ltn.diag 设计为与量词一起使用。每个量词在执行聚合后会自动调用 ltn.undiag,以便变量在公式外保持正常行为。因此,建议仅在量化公式中使用 ltn.diag,如下所示:
x, l = ltn.diag(x, l)
print(x.free_vars)
['diag_x_l']
print(l.free_vars)
['diag_x_l']
print(Forall([x, l], C(x, l)).value) # Aggregates only on the 100 "zipped" pairs.
# Automatically calls `ltn.undiag`
tensor(0.3382, grad_fn=<RsubBackward1>)
print(x.free_vars)
['x']
print(l.free_vars)
['l']
注意量词调用 ltn.undiag。在量化执行完毕后,两个变量会恢复它们的原始标签。
❓思考题
- Q11:为什么不能在
torch.Size([100, 100])的基础上,进一步获取torch.Size([100])的分类结果?
3.3 受限量词
有时我们希望对满足某个布尔条件的元素集合进行量化。
假设 是某个领域中的一个变量,而 是一个掩码函数,它为领域中的每个元素返回布尔值(即 或 ,而不是连续真值度 )。
在受限量化中,量化的形式如下:
- 这意味着“每个满足 的 也满足 ”
- 这意味着“某些满足 的 也满足 ”
掩码 也可以依赖于公式中的其他变量。例如,量化 也是一个有效的句子。
让我们考虑以下示例,它表示存在一个欧几里得距离 ,在此距离下所有的点对 和 应被视为相似:
在这个例子中, 是一个度量两个点相似度的谓词,而 dist 是一个计算两个点之间欧几里得距离的函数。
Eq = ltn.Predicate(func=lambda x, y: torch.exp(-torch.norm(x - y, dim=1))) # predicate measuring similarity
points = torch.rand((50, 2)) # 50 values in [0,1]^2
x = ltn.Variable("x", points)
y = ltn.Variable("y", points)
d = ltn.Variable("d", torch.tensor([.1,.2,.3,.4,.5,.6,.7,.8,.9]))
dist = lambda x, y: torch.unsqueeze(torch.norm(x.value - y.value, dim=1), 1) # function measuring euclidian distance
Exists(d,
Forall([x, y],
Eq(x, y),
cond_vars=[x, y, d],
cond_fn=lambda x, y, d: dist(x, y) < d.value
)).value
tensor(0.7583, dtype=torch.float64)
如示例所示,要使用受限量化,只需指定一些条件变量(cond_vars 参数)和一个条件函数(cond_fn 参数)。特别地,cond_vars 需要一个 LTN 变量的列表,而 cond_fn 需要一个函数。该函数计算一个布尔掩码,然后该掩码用于选择要进行聚合计算的值。
在这个特定示例中,受限量化用于对距离小于某个阈值的点对进行聚合,阈值由变量 指定。所有其他点对在聚合过程中将不被考虑。
受限选项特别有用,因为它仅在满足条件 的域子集上传播梯度。
4. ⚙️ 模糊算子与超参数配置
4.1 稳定的乘积配置
为了避免一些运算符在其定义域的某些部分上的梯度问题,例如梯度消失、单次传递梯度(只有决定结果的输入有梯度,其他全部没梯度)、梯度爆炸,推荐在 LTN 中使用如下的乘积配置:
-
not:标准否定 -
and:乘积 t-范数 -
or:乘积 t-余范数(概率和) -
蕴涵:赖辛巴赫蕴涵
-
存在量化(
exists):广义均值(p-均值) -
全称量化(
for all):“相对于真值的偏差”的广义均值(p-均值误差)
目前,这种“乘积配置”并非完全没有问题:
- 乘积 t-范数在边缘情况 时梯度消失
- 乘积 t-余范数在边缘情况 时梯度消失
- 赖辛巴赫蕴涵在边缘情况 , 时梯度消失
pMean在边缘情况 时梯度爆炸pMeanError在边缘情况 时梯度爆炸
然而,所有这些问题发生在边缘情况下,可以通过以下“技巧”轻松修复:
-
如果边缘情况发生在输入 为 时,我们将每个输入修改为
-
如果边缘情况发生在输入 为 时,我们将每个输入修改为
其中 是一个小的正值,例如 。
这个“技巧”给我们提供了这些运算符的稳定版本。在没有梯度问题的意义上是稳定的。
通过使用布尔参数 stable,可以触发这些运算符的稳定版本。在初始化运算符时,可以为 stable 设置默认值,或者在每次调用运算符时使用不同的值。
ltn.fuzzy_ops.AggregPMeanError(p=4, stable=True)
4.2 广义均值中的超参数
pMean 和 pMeanError 的超参数 提供了在编写更严格或更宽松的公式时的灵活性,以便根据应用场景处理数据中的离群值。然而, 应该谨慎设置,因为它可能会对 LTN 的训练产生重要影响。
虽然在查询时设置较高的 值可能很有吸引力,但在学习设置中,这很快会导致“单次传递”运算符,它将在每一步过度关注离群值。也就是说,在此步骤中,梯度可能会过拟合一个输入,可能会对其他输入的训练造成伤害。建议不要设置过高的 值。
📝 总结
这一篇讲解了 LTN 中的非逻辑符号、连接词、量词,主要涉及基本概念,并举了一些简单的示例进行理解。将 T&Q&A 中的 T&A 内化至行文中,Q 显化在相应部分的结尾,希望能帮助读者边读边思考、边深入理解。
欢迎大家在评论区整理出自己的答案,也期待大家提出更多的问题 💬
🚀 下一篇预告
下一篇将进入更实用的部分:
- 基于 LTN 的训练过程
- 知识库(Knowledge Base)的定义
- 如何把逻辑规则真正加入模型学习中
如果你是从“会 PyTorch,但不会逻辑”这个状态开始学,到了这里其实已经搭好了最关键的桥梁。