通俗讲解LTN中的非逻辑符号、连接词、量词

0 阅读2分钟

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 的。每个常量 cc 都被映射到:

G(c)n1ndNRn1××nd\mathcal{G}(c) \in \bigcup\limits_{n_1 \dots n_d \in \mathbb{N}^*} \mathbb{R}^{n_1 \times \dots \times n_d}中。

论域中的对象可以是任何阶的张量。例如,0 阶张量对应一个标量,1 阶张量对应一个向量,2 阶张量对应一个矩阵,以此类推。

例如,我们定义:

G(c1)=(2.1,3)\mathcal{G}(c_1)=(2.1,3)G(c2)=(4.23.541.31.8)\mathcal{G}(c_2)=\begin{pmatrix}4.2 & 3 & .5\\ 4 & -1.3 & 1.8\end{pmatrix}

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 过程:

  1. 第一步:定义抽象逻辑符号 先在逻辑系统里定义一个抽象的符号,比如叫 c1,这个符号本身没有任何数值,只是一个“占位符名字”,代表“论域里的某个个体”。

  2. 第二步:建立符号到张量的映射 定义接地函数 G\mathcal{G}(LTN 库底层实现),把这个抽象符号 c1 映射到具体的实数张量 [2.1, 3] 上,也就是:

    G(c1)=[2.1,3]\mathcal{G}(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 中的谓词是从某个 nn 元输入值空间1^1映射到 [0,1][0, 1] 区间的函数。描述的是“nn 个个体之间的关系”程度。在 LTN 中,谓词可以是神经网络或任何实现此映射的函数。

在 LTN 中构造谓词有不同的方法。构造函数 ltn.Predicate(model, func) 提供了两种构造方式:

  • 如果 model 参数不为 None,则通过使用作为输入提供的 torch.nn.Module 模型实例来构造谓词;
  • 如果 model 参数为 Nonefunc 参数不为 None,则通过使用输入的函数来构造谓词。使用函数来构造谓词通常用于没有权重跟踪的小型数学操作(不可训练的函数)。

以下定义了一个使用 func 参数的谓词 P1P_1 和一个使用 model 参数的谓词 P2P_2

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)

在谓词 P1P_1 的定义中,使用的是 func 参数,需要指定一个手工函数。

假设我们需要 P1P_1 表示 “xx 属于以 μ\mu 为中心的某个概念/类别”的隶属度,可以指定一个径向基函数:

func=lambda x: torch.exp(-torch.norm(x - mu.value, dim=1))
  • xx 接近 μ\mu 时,xμ0|x - \mu| \approx 0,则 P1(x)e0=1P_1(x) \approx e^0 = 1(真值高)
  • xx 远离 μ\mu 时,xμ|x - \mu| \to \infty,则 P1(x)0P_1(x) \to 0(真值低)

那么,此时 P1P_1 是一个径向基函数(RBF)形式的谓词:

P1(x)=exp(xμ)P_1(x) = \exp\left(-|x - \mu|\right)

或更详细地表示为:

P1(x)=ei(xiμi)2=exμ2P_1(x) = e^{-\sqrt{\sum_{i}(x_i - \mu_i)^2}} = e^{-|x - \mu|_2}

P1P_1 中,访问了常量 muvalue 属性,因为 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 实例。访问 LTNObjectvalue 属性,可以获得谓词的实际值。

上面展示了一个一元谓词。如果一个 LTN 谓词(或 LTN 函数)需要多个输入,例如 P4(x1,x2)P_4(x_1, x_2),则参数必须用逗号分隔。此时,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^1nn 个输入论域构成的笛卡尔积空间,不等同于“任何阶的张量”,它的核心含义是“nn 个输入论域的笛卡尔积空间”——D1×D2××Dn\mathcal{D}_1 \times \mathcal{D}_2 \times \dots \times \mathcal{D}_n

而这个空间里的每个输入个体,才可以是任意阶的实数张量——n1ndNRn1××nd\bigcup\limits_{n_1 \dots n_d \in \mathbb{N}^*} \mathbb{R}^{n_1 \times \dots \times n_d}


1.3 函数

LTN 中的函数是任何将 nn 个个体(同 nn 元输入值空间1^1)映射到一个张量域中的个体的数学函数。输入 nn 个个体,输出任意张量域的新个体,用来构造新的逻辑项(生成“新的事物/特征”)。

在 LTN 中构造函数有不同的方法。构造函数 ltn.Function(model, func) 提供了两种构造方式:

  • 如果 model 参数不为 None,则通过使用作为输入提供的 torch.nn.Module 模型实例来构造函数;
  • 如果 model 参数为 Nonefunc 参数不为 None,则通过使用输入的函数来构造函数。使用函数来构造函数通常用于没有权重跟踪的小型数学操作(不可训练的函数)。

以下定义了一个使用 func 参数的函数 f1f_1 和一个使用 model 参数的函数 f2f_2

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 实例。访问 LTNObjectvalue 属性,可以获得函数返回的实际值。

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)
核心作用描述“nn 个个体的性质/关系的满足程度”描述“从 nn 个个体到 1 个新个体的映射/变换”
数学表达G(P):D1××Dn[0,1]\mathcal{G}(P): \mathcal{D}_1 \times \dots \times \mathcal{D}_n \to [0,1]G(f):D1××DnDout\mathcal{G}(f): \mathcal{D}_1 \times \dots \times \mathcal{D}*n \to \mathcal{D}*{out}
代码对应P1(x):x 和 mu 相似的真度;P4(x,y):x 和 y 满足目标关系的真度f1(x,y):x 减 y 得到的新向量;f2(x):x 经过 MLP 变换得到的新特征向量

LTN 函数和谓词的核心差别,完全对应一阶逻辑里“项(Term)”和“原子公式(Atomic Formula)”的差别:

  • 谓词:是“打分器”,输入 nn 个个体,输出 [0,1][0,1] 区间的真度,用来构成逻辑命题(判断“是不是/满不满足”);
  • 函数:是“变换器”,输入 nn 个个体,输出任意张量域的新个体,用来构造新的逻辑项(生成“新的事物/特征”)。

❓思考题

  • Q3:LTN 的函数和谓词的核心区别是什么?

1.4 变量

LTN 中的变量是来自某个域的个体/常量的序列。在逻辑中,变量对于编写带量词的语句非常有用,例如 x P(x)\forall x\ P(x)

相较于常量代表论域里的单个物体来说,变量则代表了论域里的一批个体的占位符/代表。同时,需要注意,变量是一个序列而不是集合,也就是说,序列中可以包含相同的值多次。

这里,要强调一下,普通编程语言(Python/Java/C++)里的变量/常量和 LTN 里的变量/常量的区别:

普通编程语言的变量/常量对应计算机科学中的“标识符”,是数据的别名,用于简化对内存地址的操作,在数据形态上,可存储对应编程语言中任意类型的数据,无固定维度语义。

LTN 里的变量与常量对应一阶逻辑学定义,和“存数据”无关,其对应的数据类型是 torch.Tensor

  • LTN 常量(Constant) = 逻辑世界里的单个具体实体,永远代表某一个东西(一个样本、一个原型点、一个物体)
  • LTN 变量(Variable) = 逻辑世界里的一批实体的占位符,永远代表 NN 个东西(所有样本、一组数据),专门给量词 \forall(所有) / \exists(存在)使用,数值不修改、不优化

以下定义了两个变量 xxyy,分别从 R2\mathbb{R}^2 的正态分布中抽取了 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 属性是空列表,因为常量不需要被约束,它本身就是固定的、确定的个体,变量才需要被约束。

对一个变量 nn 个个体的术语/谓词进行评估(逐一代入计算),结果会得到 nn 个输出值,其中第 ii 个输出值对应于使用第 ii 个个体计算的术语。

类似地,对 kk 个变量 (x1,,xk)(x_1, \dots, x_k) 进行评估(逐一代入计算),每个变量分别有 n1,,nkn_1, \dots, n_k 个个体,结果将是一个包含 n1××nkn_1 \times \dots \times n_k 个值的张量。该结果组织成一个张量,其中前 kk 个维度可以索引以检索每个变量对应的结果。这个张量被封装在一个 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>)

P4P_4 的结果具有形状 10×510 \times 5,因为 xx 有 10 个个体,yy 有 5 个个体。 结果的第一个单元格是 P4P_4xx 的第一个个体和 yy 的第一个个体的评估,第二个单元格是 P4P_4xx 的第一个个体和 yy 的第二个个体的评估,以此类推。

注意,在表示 P4P_4 评估结果的 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')

函数输出不再是 [0.,1.][0., 1.] 区间内的值,因为我们现在应用的是一个函数。还要注意形状的变化。在谓词的情况下,结果的形状是 10×510 \times 5,因为结果包含了 10×510 \times 5 个真值,取值范围在 [0.,1.][0., 1.] 之间。现在,函数不再返回一个标量,而是返回一个在 R2\mathbb{R}^2 中的实数向量,对应了新形状 10×5×210 \times 5 \times 2 的最后一个维度。

在这个最后的示例中,我们对一个变量和一个常量应用了一个谓词。现在 free_vars 属性只包含 yy,因为只有变量 yy 被作为输入提供。

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 张量作为值)。变量的第一个个体将是第一个常量,而第二个个体将是第二个常量。

P2P_2xx 进行评估之后,xx 的两个个体都会有一个 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 的四个基本联结词——否定(¬\lnot)、合取(\land)、析取(\lor)和蕴涵(\rightarrow):

  • 标准的否定:¬u=1u\lnot u = 1 - u
  • 乘积 t-范数:uv=uvu \land v = uv
  • 乘积 t-余范数(概率和):uv=u+vuvu \lor v = u + v - uv
  • 赖辛巴赫蕴涵:uv=1u+uvu \rightarrow v = 1 - u + uv

其中 uuvv 表示在 [0,1][0,1] 区间内的两个真值。模糊语义的基础就是“真度”,联结词是用来组合“真的程度”的。

在 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 公式中使用这些运算符。具体而言,它负责组合具有不同变量的子公式(这些子公式可能具有不同的维度,需要进行“广播”才能应用联结词)。

在这个例子中,我们创建了两个具有不同个体数的变量,两个常量,以及一个度量二维实数空间 R2\mathbb{R}^2 中两点相似度的谓词。

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])

注意最后两行代码中打印的形状。在第一行中,由于公式中只出现了变量 xx,因此形状为 10。公式已经对每个 xx 进行了评估。

在第二行中,由于公式中同时出现了 xxyy,因此形状为 10×510 \times 5。公式已经对 xxyy 的每个组合进行了评估。

LTN 联结词返回的是 LTNObject 实例,访问联结词评估结果可以通过 value 属性或 shape() 方法,就像谓词和函数一样。

❓思考题

  • Q9:什么是模糊语义?
  • Q10:联结词只能接受真值吗?

3. 📐 量词

LTN 支持全称量化和存在量化。它们是通过聚合运算符进行构建的。如下两个聚合运算公式常用于构建存在量化和全称量化:

  • 存在量化(“exists”):

    pM(u1,,un)=(1ni=1nuip)1p,p1\mathrm{pM}(u_1,\dots,u_n) = \left( \frac{1}{n} \sum\limits_{i=1}^n u_i^p \right)^{\frac{1}{p}}, \qquad p \geq 1
  • 全称量化(“for all”):

    pME(u1,,un)=1(1ni=1n(1ui)p)1p,p1\mathrm{pME}(u_1,\dots,u_n) = 1 - \left( \frac{1}{n} \sum\limits_{i=1}^n (1-u_i)^p \right)^{\frac{1}{p}}, \qquad p \geq 1

其中 u1,,unu_1,\dots,u_n 是在 [0,1][0,1] 区间内的真值列表。

在 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])

量化的本质是“维度聚合”:全称量词 \forall 会把“被量化变量对应的维度”做模糊聚合(比如取均值、PMean、min 等,LTN 默认是模糊逻辑的聚合操作),从而移除该维度。

针对 Eq(x,y)10×510 \times 5 矩阵:

  • 被量化的变量是 x(对应矩阵的第 0 维,即 10 行)
  • 量化操作会对每一列(每个 y 个体)聚合其对应的 10 个 x 个体的相似性值
  • 聚合后,10×510 \times 5 的矩阵会被压缩为长度为 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 语义可以理解为一个软最大值,它依赖于超参数 pp

  • p1p \to 1:操作符趋向于 mean(均值)
  • p+p \to +\infty:操作符趋向于 max(最大值)

类似地,pMeanError 语义可以理解为一个软最小值:

  • p1p \to 1:操作符趋向于 mean(均值)
  • p+p \to +\infty:操作符趋向于 min(最小值)

pp 提供了灵活性,可以根据应用场景调整公式的严格性,以适应数据中的离群值。不同的 pp 选择在训练过程中可能会有强烈的影响。可以在初始化运算符时设置 pp 的默认值,或者在每次调用运算符时使用不同的值。

如下代码使用了具有不同 pp 参数值的量词。一般来说,pp 越大,存在量化越容易满足,而全称量化越难满足。

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 个(或更多)变量,有些场景下我们希望仅对特定的对(或元组)表达语句,使得第 ii 个元组包含变量的第 ii 个实例。例如,确保只在“正确的样本-标签对”上学习。

换句话说,在某些情况下,我们不希望在所有可能的变量个体组合上评估公式。

我们使用 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

  • 变量 xx 表示 R2×2\mathbb{R}^{2\times2} 中的 100 个个体
  • 变量 ll 表示 N3\mathbb{N}^3 中的 100 个 one-hot 标签(3 个可能的类别)
  • ll 根据 xx 进行构建,使得每一对 (xi,li)(x_i, l_i),对于 i=0..100i=0..100 表示数据集中的一个正确示例
  • 分类器 C(x,l)C(x, l) 给出样本 xx 对应标签 ll 的置信度值,范围在 [0,1][0,1] 之间
# 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 将只计算它们的“压缩”结果(而不是通常的“广播”)。换句话说,公式将只在特定的个体元组上进行评估,而不是在变量的所有可能个体组合上进行评估。

可以观察到,第一个评估的形状是 100×100100 \times 100。之所以这样,是因为 LTN 生成了 xxll 的所有可能个体组合,然后应用谓词。注意在应用 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 受限量词

有时我们希望对满足某个布尔条件的元素集合进行量化。

假设 xx 是某个领域中的一个变量,而 mm 是一个掩码函数,它为领域中的每个元素返回布尔值(即 0011,而不是连续真值度 [0,1][0,1])。

在受限量化中,量化的形式如下:

  • (x:m(x)) ϕ(x)(\forall x: m(x)) \ \phi(x) 这意味着“每个满足 m(x)m(x)xx 也满足 ϕ(x)\phi(x)
  • (x:m(x)) ϕ(x)(\exists x: m(x)) \ \phi(x) 这意味着“某些满足 m(x)m(x)xx 也满足 ϕ(x)\phi(x)

掩码 mm 也可以依赖于公式中的其他变量。例如,量化 y(x:m(x,y)) ϕ(x,y)\exists y (\forall x: m(x,y)) \ \phi(x,y) 也是一个有效的句子。

让我们考虑以下示例,它表示存在一个欧几里得距离 dd,在此距离下所有的点对 xxyy 应被视为相似:

d (x,y:dist(x,y)<d) (Eq(x,y))\exists d \ (\forall x,y : \mathrm{dist}(x,y) < d)\ (\mathrm{Eq}(x,y))

在这个例子中,EqEq 是一个度量两个点相似度的谓词,而 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 需要一个函数。该函数计算一个布尔掩码,然后该掩码用于选择要进行聚合计算的值。

在这个特定示例中,受限量化用于对距离小于某个阈值的点对进行聚合,阈值由变量 dd 指定。所有其他点对在聚合过程中将不被考虑。

受限选项特别有用,因为它仅在满足条件 mm 的域子集上传播梯度。


4. ⚙️ 模糊算子与超参数配置

4.1 稳定的乘积配置

为了避免一些运算符在其定义域的某些部分上的梯度问题,例如梯度消失、单次传递梯度(只有决定结果的输入有梯度,其他全部没梯度)、梯度爆炸,推荐在 LTN 中使用如下的乘积配置:

  • not:标准否定 ¬u=1u\lnot u = 1 - u

  • and:乘积 t-范数 uv=uvu \land v = uv

  • or:乘积 t-余范数(概率和)uv=u+vuvu \lor v = u + v - uv

  • 蕴涵:赖辛巴赫蕴涵 uv=1u+uvu \rightarrow v = 1 - u + uv

  • 存在量化(exists):广义均值(p-均值)

    pM(u1,,un)=(1ni=1nuip)1p,p1\mathrm{pM}(u_1, \dots, u_n) = \left( \frac{1}{n} \sum\limits_{i=1}^n u_i^p \right)^{\frac{1}{p}}, \qquad p \geq 1
  • 全称量化(for all):“相对于真值的偏差”的广义均值(p-均值误差)

    pME(u1,,un)=1(1ni=1n(1ui)p)1p,p1\mathrm{pME}(u_1,\dots,u_n) = 1 - \left( \frac{1}{n} \sum\limits_{i=1}^n (1-u_i)^p \right)^{\frac{1}{p}}, \qquad p \geq 1

目前,这种“乘积配置”并非完全没有问题:

  • 乘积 t-范数在边缘情况 u=v=0u=v=0 时梯度消失
  • 乘积 t-余范数在边缘情况 u=v=1u=v=1 时梯度消失
  • 赖辛巴赫蕴涵在边缘情况 u=0u=0v=1v=1 时梯度消失
  • pMean 在边缘情况 u1==un=0u_1 = \dots = u_n = 0 时梯度爆炸
  • pMeanError 在边缘情况 u1==un=1u_1 = \dots = u_n = 1 时梯度爆炸

然而,所有这些问题发生在边缘情况下,可以通过以下“技巧”轻松修复:

  • 如果边缘情况发生在输入 uu00 时,我们将每个输入修改为

    u=(1ϵ)u+ϵu' = (1-\epsilon)u + \epsilon
  • 如果边缘情况发生在输入 uu11 时,我们将每个输入修改为

    u=(1ϵ)uu' = (1-\epsilon)u

其中 ϵ\epsilon 是一个小的正值,例如 1e51\mathrm{e}{-5}

这个“技巧”给我们提供了这些运算符的稳定版本。在没有梯度问题的意义上是稳定的。

通过使用布尔参数 stable,可以触发这些运算符的稳定版本。在初始化运算符时,可以为 stable 设置默认值,或者在每次调用运算符时使用不同的值。

ltn.fuzzy_ops.AggregPMeanError(p=4, stable=True)

4.2 广义均值中的超参数

pMeanpMeanError 的超参数 pp 提供了在编写更严格或更宽松的公式时的灵活性,以便根据应用场景处理数据中的离群值。然而,pp 应该谨慎设置,因为它可能会对 LTN 的训练产生重要影响。

虽然在查询时设置较高的 pp 值可能很有吸引力,但在学习设置中,这很快会导致“单次传递”运算符,它将在每一步过度关注离群值。也就是说,在此步骤中,梯度可能会过拟合一个输入,可能会对其他输入的训练造成伤害。建议不要设置过高的 pp 值。


📝 总结

这一篇讲解了 LTN 中的非逻辑符号、连接词、量词,主要涉及基本概念,并举了一些简单的示例进行理解。将 T&Q&A 中的 T&A 内化至行文中,Q 显化在相应部分的结尾,希望能帮助读者边读边思考、边深入理解。

欢迎大家在评论区整理出自己的答案,也期待大家提出更多的问题 💬

🚀 下一篇预告

下一篇将进入更实用的部分:

  • 基于 LTN 的训练过程
  • 知识库(Knowledge Base)的定义
  • 如何把逻辑规则真正加入模型学习中

如果你是从“会 PyTorch,但不会逻辑”这个状态开始学,到了这里其实已经搭好了最关键的桥梁。