ltntorch 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/AGENTS.md +34 -0
- data/CONTRIBUTING.md +66 -0
- data/changelog.md +68 -0
- data/code_of_conduct.md +84 -0
- data/examples/1_binary_classification.rb +108 -0
- data/examples/2_multi_class_single_label_classification.rb +130 -0
- data/examples/3_multi_class_multi_label_classification.rb +158 -0
- data/examples/4_semi_supervised_pattern_recognition.rb +249 -0
- data/examples/5_regression.rb +103 -0
- data/examples/6_clustering.rb +126 -0
- data/examples/7_learning_embeddings_with_ltn.rb +166 -0
- data/examples/datasets/crabs.dat +201 -0
- data/examples/datasets/iris_test.csv +31 -0
- data/examples/datasets/iris_training.csv +121 -0
- data/examples/datasets/readme.md +9 -0
- data/examples/datasets/real-estate.csv +415 -0
- data/examples/support/example.rb +92 -0
- data/examples/support/mnist.rb +77 -0
- data/lib/ltn/connective.rb +56 -0
- data/lib/ltn/constant.rb +30 -0
- data/lib/ltn/diag.rb +54 -0
- data/lib/ltn/function.rb +28 -0
- data/lib/ltn/fuzzy_ops.rb +428 -0
- data/lib/ltn/lambda_model.rb +24 -0
- data/lib/ltn/ltn_object.rb +47 -0
- data/lib/ltn/model_wrapper.rb +72 -0
- data/lib/ltn/predicate.rb +38 -0
- data/lib/ltn/process_ltn_objects.rb +58 -0
- data/lib/ltn/quantifier.rb +176 -0
- data/lib/ltn/variable.rb +49 -0
- data/lib/ltn/version.rb +8 -0
- data/lib/ltn.rb +45 -0
- data/license.md +22 -0
- data/readme.md +166 -0
- data/sig/ltn.rbs +207 -0
- data/tutorials/1_grounding_non_logical_symbols.rb +146 -0
- data/tutorials/2_grounding_connectives.rb +126 -0
- data/tutorials/2b_operators_and_gradients.rb +78 -0
- data/tutorials/3_knowledgebase_and_learning.rb +124 -0
- metadata +94 -0
data/sig/ltn.rbs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
module LTN
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
# The device on which LTN places the groundings of its variables.
|
|
8
|
+
def self.device: () -> untyped
|
|
9
|
+
def self.device=: (untyped device) -> untyped
|
|
10
|
+
|
|
11
|
+
# A grounding together with the labels of its free variables.
|
|
12
|
+
class LTNObject
|
|
13
|
+
attr_accessor value: untyped
|
|
14
|
+
attr_accessor free_vars: Array[String]
|
|
15
|
+
|
|
16
|
+
def initialize: (untyped value, Array[String] var_labels) -> void
|
|
17
|
+
def shape: () -> Array[Integer]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module FuzzyOps
|
|
21
|
+
EPS: Float
|
|
22
|
+
|
|
23
|
+
def self.pi_0: (untyped x) -> untyped
|
|
24
|
+
def self.pi_1: (untyped x) -> untyped
|
|
25
|
+
def self.check_values: (*untyped values) -> void
|
|
26
|
+
def self.check_mask: (untyped mask, untyped xs) -> void
|
|
27
|
+
def self.reduction_dims: (Integer | Array[Integer] | nil dim) -> Array[Integer]
|
|
28
|
+
def self.where: (untyped condition, untyped input, untyped other) -> untyped
|
|
29
|
+
|
|
30
|
+
module Stable
|
|
31
|
+
attr_accessor stable: bool
|
|
32
|
+
def initialize: (?stable: bool) -> void
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class ConnectiveOperator
|
|
36
|
+
def call: (*untyped args, **untyped kwargs) -> untyped
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class UnaryConnectiveOperator < ConnectiveOperator
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class BinaryConnectiveOperator < ConnectiveOperator
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class NotStandard < UnaryConnectiveOperator
|
|
46
|
+
def call: (untyped x) -> untyped
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class NotGodel < UnaryConnectiveOperator
|
|
50
|
+
def call: (untyped x) -> untyped
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class AndMin < BinaryConnectiveOperator
|
|
54
|
+
def call: (untyped x, untyped y) -> untyped
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class AndProd < BinaryConnectiveOperator
|
|
58
|
+
include Stable
|
|
59
|
+
def call: (untyped x, untyped y, ?stable: bool?) -> untyped
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class AndLuk < BinaryConnectiveOperator
|
|
63
|
+
def call: (untyped x, untyped y) -> untyped
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class OrMax < BinaryConnectiveOperator
|
|
67
|
+
def call: (untyped x, untyped y) -> untyped
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class OrProbSum < BinaryConnectiveOperator
|
|
71
|
+
include Stable
|
|
72
|
+
def call: (untyped x, untyped y, ?stable: bool?) -> untyped
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class OrLuk < BinaryConnectiveOperator
|
|
76
|
+
def call: (untyped x, untyped y) -> untyped
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class ImpliesKleeneDienes < BinaryConnectiveOperator
|
|
80
|
+
def call: (untyped x, untyped y) -> untyped
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class ImpliesGodel < BinaryConnectiveOperator
|
|
84
|
+
def call: (untyped x, untyped y) -> untyped
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class ImpliesReichenbach < BinaryConnectiveOperator
|
|
88
|
+
include Stable
|
|
89
|
+
def call: (untyped x, untyped y, ?stable: bool?) -> untyped
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class ImpliesGoguen < BinaryConnectiveOperator
|
|
93
|
+
include Stable
|
|
94
|
+
def call: (untyped x, untyped y, ?stable: bool?) -> untyped
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class ImpliesLuk < BinaryConnectiveOperator
|
|
98
|
+
def call: (untyped x, untyped y) -> untyped
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class Equiv < BinaryConnectiveOperator
|
|
102
|
+
attr_reader and_op: BinaryConnectiveOperator
|
|
103
|
+
attr_reader implies_op: BinaryConnectiveOperator
|
|
104
|
+
|
|
105
|
+
def initialize: (BinaryConnectiveOperator and_op, BinaryConnectiveOperator implies_op) -> void
|
|
106
|
+
def call: (untyped x, untyped y) -> untyped
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
type dim = Integer | Array[Integer] | nil
|
|
110
|
+
|
|
111
|
+
class AggregationOperator
|
|
112
|
+
def call: (*untyped args, **untyped kwargs) -> untyped
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
module PMeanParameters
|
|
116
|
+
attr_accessor p: Numeric
|
|
117
|
+
attr_accessor stable: bool
|
|
118
|
+
def initialize: (?p: Numeric, ?stable: bool) -> void
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class AggregMin < AggregationOperator
|
|
122
|
+
def call: (untyped xs, ?dim: dim, ?keepdim: bool, ?mask: untyped) -> untyped
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class AggregMean < AggregationOperator
|
|
126
|
+
def call: (untyped xs, ?dim: dim, ?keepdim: bool, ?mask: untyped) -> untyped
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class AggregPMean < AggregationOperator
|
|
130
|
+
include PMeanParameters
|
|
131
|
+
def call: (untyped xs, ?dim: dim, ?keepdim: bool, ?mask: untyped, ?p: Numeric?, ?stable: bool?) -> untyped
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class AggregPMeanError < AggregationOperator
|
|
135
|
+
include PMeanParameters
|
|
136
|
+
def call: (untyped xs, ?dim: dim, ?keepdim: bool, ?mask: untyped, ?p: Numeric?, ?stable: bool?) -> untyped
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
class SatAgg
|
|
140
|
+
attr_reader agg_op: AggregationOperator
|
|
141
|
+
|
|
142
|
+
def initialize: (?AggregationOperator agg_op) -> void
|
|
143
|
+
def call: (*untyped closed_formulas) -> untyped
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class Constant < LTNObject
|
|
148
|
+
def initialize: (untyped value, ?trainable: bool) -> void
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
class Variable < LTNObject
|
|
152
|
+
attr_reader latent_var: String
|
|
153
|
+
|
|
154
|
+
def initialize: (String var_label, untyped individuals, ?add_batch_dim: bool) -> void
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def self.process_ltn_objects: (Array[LTNObject] objects) -> [Array[LTNObject], Array[String], Array[Integer]]
|
|
158
|
+
def self.diag: (*Variable vars) -> Array[Variable]
|
|
159
|
+
def self.undiag: (*Variable vars) -> Array[Variable]
|
|
160
|
+
|
|
161
|
+
class LambdaModel
|
|
162
|
+
attr_reader func: untyped
|
|
163
|
+
|
|
164
|
+
def initialize: (untyped func) -> void
|
|
165
|
+
def forward: (*untyped inputs, **untyped kwargs) -> untyped
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
module ModelWrapper
|
|
169
|
+
attr_reader model: untyped
|
|
170
|
+
|
|
171
|
+
def initialize: (?untyped model, ?func: untyped) ?{ (*untyped) -> untyped } -> void
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
class Predicate
|
|
175
|
+
include ModelWrapper
|
|
176
|
+
|
|
177
|
+
def forward: (*LTNObject inputs, **untyped kwargs) -> LTNObject
|
|
178
|
+
def call: (*LTNObject inputs, **untyped kwargs) -> LTNObject
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
class Function
|
|
182
|
+
include ModelWrapper
|
|
183
|
+
|
|
184
|
+
def forward: (*LTNObject inputs, **untyped kwargs) -> LTNObject
|
|
185
|
+
def call: (*LTNObject inputs, **untyped kwargs) -> LTNObject
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
class Connective
|
|
189
|
+
attr_reader connective_op: FuzzyOps::ConnectiveOperator
|
|
190
|
+
|
|
191
|
+
def initialize: (FuzzyOps::ConnectiveOperator connective_op) -> void
|
|
192
|
+
def call: (*LTNObject operands, **untyped kwargs) -> LTNObject
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
class Quantifier
|
|
196
|
+
QUANTIFIERS: Hash[Symbol | String, Symbol]
|
|
197
|
+
|
|
198
|
+
attr_reader agg_op: FuzzyOps::AggregationOperator
|
|
199
|
+
attr_reader quantifier: Symbol
|
|
200
|
+
|
|
201
|
+
def initialize: (FuzzyOps::AggregationOperator agg_op, Symbol | String quantifier) -> void
|
|
202
|
+
def call: (Variable | Array[Variable] vars, LTNObject formula, ?cond_vars: (Variable | Array[Variable])?,
|
|
203
|
+
?cond_fn: untyped, **untyped kwargs) ?{ (*LTNObject) -> untyped } -> LTNObject
|
|
204
|
+
def self.compute_mask: (LTNObject formula, Array[Variable] cond_vars, untyped cond_fn, Array[String] aggregation_vars) -> [LTNObject, LTNObject]
|
|
205
|
+
def self.transpose_vars: (LTNObject object, Array[String] new_vars_order) -> LTNObject
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Released under the MIT License.
|
|
5
|
+
# Copyright, 2026, by Mauro Tortonesi.
|
|
6
|
+
# Ported from LTNtorch's tutorials/1-grounding_non_logical_symbols.ipynb, copyright 2021-2024 by
|
|
7
|
+
# Tommaso Carraro; see the notebook for the full discussion.
|
|
8
|
+
#
|
|
9
|
+
# Tutorial 1: grounding the non-logical symbols (constants, predicates,
|
|
10
|
+
# functions, variables).
|
|
11
|
+
#
|
|
12
|
+
# bundle exec ruby tutorials/1_grounding_non_logical_symbols.rb
|
|
13
|
+
#
|
|
14
|
+
# In LTN's Real Logic, a *grounding* maps every term of a first-order
|
|
15
|
+
# language to a tensor of real numbers, and every formula to a truth value in
|
|
16
|
+
# [0, 1]. This tutorial covers the signature: constants, predicates,
|
|
17
|
+
# functions and variables. Tutorial 2 covers connectives and quantifiers.
|
|
18
|
+
|
|
19
|
+
require "ltn"
|
|
20
|
+
|
|
21
|
+
Torch.manual_seed(2020)
|
|
22
|
+
|
|
23
|
+
def show(label, value)
|
|
24
|
+
puts "#{label.ljust(24)} #{value.inspect}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
puts "== Constants"
|
|
28
|
+
# A constant is an individual of the domain, grounded as a tensor of any
|
|
29
|
+
# rank: here a vector of R^2 and a 2x3 matrix.
|
|
30
|
+
c1 = LTN::Constant.new(Torch.tensor([2.1, 3.0]))
|
|
31
|
+
c2 = LTN::Constant.new(Torch.tensor([[4.2, 3.0, 2.5], [4.0, -1.3, 1.8]]))
|
|
32
|
+
# A trainable constant is an embedding: its tensor requires grad, and is
|
|
33
|
+
# learned with the rest of the knowledge base (see example 7).
|
|
34
|
+
c3 = LTN::Constant.new(Torch.tensor([0.0, 0.0]), trainable: true)
|
|
35
|
+
show("c1.value", c1.value)
|
|
36
|
+
show("c2.shape", c2.shape)
|
|
37
|
+
show("c3.value", c3.value)
|
|
38
|
+
# Constants are LTNObjects: a value, and the labels of the free variables
|
|
39
|
+
# appearing in it (none, for a constant).
|
|
40
|
+
show("c1.free_vars", c1.free_vars)
|
|
41
|
+
|
|
42
|
+
puts "\n== Predicates"
|
|
43
|
+
# A predicate maps individuals to truth values in [0, 1]. It is grounded
|
|
44
|
+
# either by a function (or block), for fixed predicates without trainable
|
|
45
|
+
# weights, or by a Torch::NN::Module, for learnable ones.
|
|
46
|
+
mu = LTN::Constant.new(Torch.tensor([2.0, 3.0]))
|
|
47
|
+
p1 = LTN::Predicate.new { |x| Torch.exp(Torch.neg(Torch.norm(x - mu.value, 2, dim: 1))) }
|
|
48
|
+
|
|
49
|
+
class ModelP2 < Torch::NN::Module
|
|
50
|
+
def initialize
|
|
51
|
+
super
|
|
52
|
+
@elu = Torch::NN::ELU.new
|
|
53
|
+
@sigmoid = Torch::NN::Sigmoid.new
|
|
54
|
+
@dense1 = Torch::NN::Linear.new(2, 5)
|
|
55
|
+
@dense2 = Torch::NN::Linear.new(5, 1) # one value in [0, 1]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def forward(x)
|
|
59
|
+
@sigmoid.call(@dense2.call(@elu.call(@dense1.call(x))))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
p2 = LTN::Predicate.new(ModelP2.new)
|
|
63
|
+
|
|
64
|
+
# Predicates take LTN objects, and return an LTNObject whose value holds the
|
|
65
|
+
# truth values.
|
|
66
|
+
show("P1(c1)", p1.call(c1).value)
|
|
67
|
+
show("P1(LTN::Constant [4.5, 0.8])", p1.call(LTN::Constant.new(Torch.tensor([4.5, 0.8]))).value)
|
|
68
|
+
show("P2(LTN::Constant [3, 4.8])", p2.call(LTN::Constant.new(Torch.tensor([3.0, 4.8]))).value)
|
|
69
|
+
show("P1(c1) as an LTNObject", p1.call(c1))
|
|
70
|
+
|
|
71
|
+
# LTN adds a batch dimension in front of the inputs it gives to the model, so
|
|
72
|
+
# models work on dim 1, the features. A binary predicate gets two tensors.
|
|
73
|
+
class ModelP4 < Torch::NN::Module
|
|
74
|
+
def initialize
|
|
75
|
+
super
|
|
76
|
+
@elu = Torch::NN::ELU.new
|
|
77
|
+
@sigmoid = Torch::NN::Sigmoid.new
|
|
78
|
+
@dense1 = Torch::NN::Linear.new(4, 5)
|
|
79
|
+
@dense2 = Torch::NN::Linear.new(5, 1)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def forward(x, y)
|
|
83
|
+
x = Torch.cat([x, y], dim: 1)
|
|
84
|
+
@sigmoid.call(@dense2.call(@elu.call(@dense1.call(x))))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
p4 = LTN::Predicate.new(ModelP4.new)
|
|
88
|
+
c4 = LTN::Constant.new(Torch.tensor([4.5, 0.8]))
|
|
89
|
+
show("P4(c1, c4)", p4.call(c1, c4).value)
|
|
90
|
+
|
|
91
|
+
puts "\n== Functions"
|
|
92
|
+
# A function maps individuals to an individual (a tensor of any shape), and
|
|
93
|
+
# is grounded like a predicate: by a function or by a module.
|
|
94
|
+
f1 = LTN::Function.new { |x, y| x - y }
|
|
95
|
+
|
|
96
|
+
class MyModel < Torch::NN::Module
|
|
97
|
+
def initialize
|
|
98
|
+
super
|
|
99
|
+
@dense1 = Torch::NN::Linear.new(2, 10)
|
|
100
|
+
@dense2 = Torch::NN::Linear.new(10, 5)
|
|
101
|
+
@relu = Torch::NN::ReLU.new
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def forward(x)
|
|
105
|
+
@dense2.call(@relu.call(@dense1.call(x)))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
f2 = LTN::Function.new(MyModel.new)
|
|
109
|
+
show("f1(c1, c4)", f1.call(c1, c4).value)
|
|
110
|
+
show("f2(c1)", f2.call(c1).value)
|
|
111
|
+
|
|
112
|
+
puts "\n== Variables"
|
|
113
|
+
# A variable is a labeled sequence of individuals (not a set: an individual
|
|
114
|
+
# may occur twice). Its free_vars are its label.
|
|
115
|
+
x = LTN::Variable.new("x", Torch.randn(10, 2))
|
|
116
|
+
y = LTN::Variable.new("y", Torch.randn(5, 2))
|
|
117
|
+
show("x.free_vars", x.free_vars)
|
|
118
|
+
|
|
119
|
+
# Evaluating a symbol on variables evaluates it on every combination of
|
|
120
|
+
# their individuals: one axis per variable, in the order in which they
|
|
121
|
+
# appear, and free_vars tells which axis is which.
|
|
122
|
+
res1 = p4.call(x, y)
|
|
123
|
+
show("P4(x, y).shape", res1.shape)
|
|
124
|
+
show("P4(x, y).free_vars", res1.free_vars)
|
|
125
|
+
show("P4(x_3, y_1)", res1.value[2, 0])
|
|
126
|
+
|
|
127
|
+
# A function's outputs keep their own dimensions after the variables' axes:
|
|
128
|
+
# f1 maps to R^2, so f1(x, y) is 10 x 5 x 2.
|
|
129
|
+
res2 = f1.call(x, y)
|
|
130
|
+
show("f1(x, y).shape", res2.shape)
|
|
131
|
+
show("f1(x, y).free_vars", res2.free_vars)
|
|
132
|
+
|
|
133
|
+
# Constants add no axis.
|
|
134
|
+
res3 = p4.call(c1, y)
|
|
135
|
+
show("P4(c1, y).shape", res3.shape)
|
|
136
|
+
show("P4(c1, y).free_vars", res3.free_vars)
|
|
137
|
+
|
|
138
|
+
puts "\n== Variables made of trainable constants"
|
|
139
|
+
# A variable can be built from the values of trainable constants, so that
|
|
140
|
+
# gradients flow back to them through any formula using the variable.
|
|
141
|
+
c1 = LTN::Constant.new(Torch.tensor([2.1, 3.0]), trainable: true)
|
|
142
|
+
c2 = LTN::Constant.new(Torch.tensor([4.5, 0.8]), trainable: true)
|
|
143
|
+
x = LTN::Variable.new("x", Torch.stack([c1.value, c2.value]))
|
|
144
|
+
p2.call(x).value.sum.backward
|
|
145
|
+
show("gradient of c1", c1.value.grad)
|
|
146
|
+
show("gradient of c2", c2.value.grad)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Released under the MIT License.
|
|
5
|
+
# Copyright, 2026, by Mauro Tortonesi.
|
|
6
|
+
# Ported from LTNtorch's tutorials/2-grounding_connectives.ipynb, copyright 2021-2024 by Tommaso Carraro;
|
|
7
|
+
# see the notebook for the full discussion.
|
|
8
|
+
#
|
|
9
|
+
# Tutorial 2: grounding connectives and quantifiers, including diagonal and
|
|
10
|
+
# guarded quantification.
|
|
11
|
+
#
|
|
12
|
+
# bundle exec ruby tutorials/2_grounding_connectives.rb
|
|
13
|
+
|
|
14
|
+
require "ltn"
|
|
15
|
+
|
|
16
|
+
Torch.manual_seed(2020)
|
|
17
|
+
|
|
18
|
+
def show(label, value)
|
|
19
|
+
puts "#{label.ljust(36)} #{value.inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
puts "== Connectives"
|
|
23
|
+
# Connectives are grounded with fuzzy semantics (LTN::FuzzyOps). The
|
|
24
|
+
# recommended ones are the standard negation 1 - u, the product t-norm u v,
|
|
25
|
+
# the probabilistic sum u + v - u v, and the Reichenbach implication
|
|
26
|
+
# 1 - u + u v (see tutorial 2b for why).
|
|
27
|
+
not_ = LTN::Connective.new(LTN::FuzzyOps::NotStandard.new)
|
|
28
|
+
and_ = LTN::Connective.new(LTN::FuzzyOps::AndProd.new)
|
|
29
|
+
or_ = LTN::Connective.new(LTN::FuzzyOps::OrProbSum.new)
|
|
30
|
+
implies = LTN::Connective.new(LTN::FuzzyOps::ImpliesReichenbach.new)
|
|
31
|
+
|
|
32
|
+
x = LTN::Variable.new("x", Torch.randn(10, 2))
|
|
33
|
+
y = LTN::Variable.new("y", Torch.randn(5, 2))
|
|
34
|
+
c1 = LTN::Constant.new(Torch.tensor([0.5, 0.0]))
|
|
35
|
+
c2 = LTN::Constant.new(Torch.tensor([4.0, 2.0]))
|
|
36
|
+
# A predicate measuring the similarity of two points.
|
|
37
|
+
eq = LTN::Predicate.new { |a, b| Torch.exp(Torch.neg(Torch.norm(a - b, 2, dim: 1))) }
|
|
38
|
+
|
|
39
|
+
show("Eq(c1, c2)", eq.call(c1, c2).value)
|
|
40
|
+
show("not Eq(c1, c2)", not_.call(eq.call(c1, c2)).value)
|
|
41
|
+
show("Eq(c1, c2) -> Eq(c2, c1)", implies.call(eq.call(c1, c2), eq.call(c2, c1)).value)
|
|
42
|
+
# LTN::Connective broadcasts its operands: a formula has one axis per
|
|
43
|
+
# variable appearing in any of its sub-formulas.
|
|
44
|
+
show("(Eq(x, c1) and Eq(x, c2)).shape", and_.call(eq.call(x, c1), eq.call(x, c2)).shape)
|
|
45
|
+
show("(Eq(x, c1) or Eq(x, y)).shape", or_.call(eq.call(x, c1), eq.call(x, y)).shape)
|
|
46
|
+
|
|
47
|
+
puts "\n== Quantifiers"
|
|
48
|
+
# Quantifiers are grounded by aggregation operators. The recommended ones
|
|
49
|
+
# are the generalized mean pMean for exists, a soft maximum, and the
|
|
50
|
+
# generalized mean of the errors pMeanError for forall, a soft minimum.
|
|
51
|
+
forall = LTN::Quantifier.new(LTN::FuzzyOps::AggregPMeanError.new(p: 2), :forall)
|
|
52
|
+
exists = LTN::Quantifier.new(LTN::FuzzyOps::AggregPMean.new(p: 2), :exists)
|
|
53
|
+
|
|
54
|
+
# Quantifying a variable aggregates its axis away; give several variables as
|
|
55
|
+
# an array.
|
|
56
|
+
show("Eq(x, y).shape", eq.call(x, y).shape)
|
|
57
|
+
show("(forall x: Eq(x, y)).shape", forall.call(x, eq.call(x, y)).shape)
|
|
58
|
+
show("forall x, y: Eq(x, y)", forall.call([x, y], eq.call(x, y)).value)
|
|
59
|
+
show("exists x, y: Eq(x, y)", exists.call([x, y], eq.call(x, y)).value)
|
|
60
|
+
show("forall x: exists y: Eq(x, y)", forall.call(x, exists.call(y, eq.call(x, y))).value)
|
|
61
|
+
|
|
62
|
+
# p sets how strict the aggregation is: as p grows, pMeanError tends to the
|
|
63
|
+
# minimum (forall), and pMean to the maximum (exists). It can be given per
|
|
64
|
+
# call.
|
|
65
|
+
show("forall x: Eq(x, c1), p = 2", forall.call(x, eq.call(x, c1), p: 2).value)
|
|
66
|
+
show("forall x: Eq(x, c1), p = 10", forall.call(x, eq.call(x, c1), p: 10).value)
|
|
67
|
+
show("exists x: Eq(x, c1), p = 2", exists.call(x, eq.call(x, c1), p: 2).value)
|
|
68
|
+
show("exists x: Eq(x, c1), p = 10", exists.call(x, eq.call(x, c1), p: 10).value)
|
|
69
|
+
|
|
70
|
+
puts "\n== Diagonal quantification"
|
|
71
|
+
# Sometimes a formula should only be evaluated on the i-th individuals of
|
|
72
|
+
# several variables together (e.g. samples and their labels), not on every
|
|
73
|
+
# combination of them. LTN.diag marks variables (with the same number of
|
|
74
|
+
# individuals) to be paired this way.
|
|
75
|
+
samples = Torch.randn(100, 2, 2) # 100 values in R^{2x2}
|
|
76
|
+
labels = Torch.randint(0, 3, [100], dtype: :int64) # one of 3 classes each
|
|
77
|
+
x = LTN::Variable.new("x", samples)
|
|
78
|
+
l = LTN::Variable.new("l", Torch.eye(3)[labels]) # one-hot labels
|
|
79
|
+
|
|
80
|
+
class ModelC < Torch::NN::Module
|
|
81
|
+
def initialize
|
|
82
|
+
super
|
|
83
|
+
@elu = Torch::NN::ELU.new
|
|
84
|
+
@softmax = Torch::NN::Softmax.new(dim: 1)
|
|
85
|
+
@dense1 = Torch::NN::Linear.new(4, 5)
|
|
86
|
+
@dense2 = Torch::NN::Linear.new(5, 3)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def forward(x, l)
|
|
90
|
+
x = Torch.flatten(x, start_dim: 1)
|
|
91
|
+
x = @softmax.call(@dense2.call(@elu.call(@dense1.call(x))))
|
|
92
|
+
Torch.sum(x * l, dim: 1)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
c = LTN::Predicate.new(ModelC.new)
|
|
96
|
+
|
|
97
|
+
show("C(x, l).shape", c.call(x, l).shape) # all 100 x 100 combinations
|
|
98
|
+
LTN.diag(x, l)
|
|
99
|
+
show("C(x, l).shape after diag", c.call(x, l).shape) # the 100 pairs only
|
|
100
|
+
show("x.free_vars, l.free_vars", [x.free_vars, l.free_vars])
|
|
101
|
+
LTN.undiag(x, l)
|
|
102
|
+
show("C(x, l).shape after undiag", c.call(x, l).shape)
|
|
103
|
+
|
|
104
|
+
# In practice, use LTN.diag inside a quantifier: the quantifier resets the
|
|
105
|
+
# variables (LTN.undiag) afterwards. Call it before building the formula, as
|
|
106
|
+
# below, since it changes the variables' labels.
|
|
107
|
+
show("forall diag(x, l): C(x, l)", forall.call(LTN.diag(x, l), c.call(x, l)).value)
|
|
108
|
+
show("x.free_vars, l.free_vars", [x.free_vars, l.free_vars])
|
|
109
|
+
|
|
110
|
+
puts "\n== Guarded quantification"
|
|
111
|
+
# A guard restricts a quantifier to the individuals satisfying a boolean
|
|
112
|
+
# condition, which may involve other variables too: "there is a distance d
|
|
113
|
+
# such that all pairs of points closer than d are similar".
|
|
114
|
+
points = Torch.rand(50, 2)
|
|
115
|
+
x = LTN::Variable.new("x", points)
|
|
116
|
+
y = LTN::Variable.new("y", points)
|
|
117
|
+
d = LTN::Variable.new("d", Torch.tensor([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]))
|
|
118
|
+
dist = ->(a, b) { Torch.norm(a.value - b.value, 2, dim: 1).unsqueeze(1) }
|
|
119
|
+
|
|
120
|
+
result = exists.call(d,
|
|
121
|
+
forall.call([x, y], eq.call(x, y), cond_vars: [x, y, d]) { |a, b, dd| dist.call(a, b).lt(dd.value) })
|
|
122
|
+
show("exists d: forall x, y with dist < d: Eq(x, y)", result.value)
|
|
123
|
+
# The guard is a function (or block) of the condition variables, returning a
|
|
124
|
+
# boolean tensor; it receives LTN objects, so it reads their values. Since
|
|
125
|
+
# only the pairs satisfying it are aggregated, gradients also flow only
|
|
126
|
+
# through them.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Released under the MIT License.
|
|
5
|
+
# Copyright, 2026, by Mauro Tortonesi.
|
|
6
|
+
# Ported from LTNtorch's tutorials/2b-operators-and-gradients.ipynb, copyright 2021-2024 by Tommaso Carraro;
|
|
7
|
+
# see the notebook, and the LTN paper (https://arxiv.org/abs/2012.13635), for the full discussion.
|
|
8
|
+
#
|
|
9
|
+
# Tutorial 2b: choosing fuzzy operators, and why their gradients matter.
|
|
10
|
+
#
|
|
11
|
+
# bundle exec ruby tutorials/2b_operators_and_gradients.rb
|
|
12
|
+
#
|
|
13
|
+
# LTN learns by gradient descent on the truth values of formulas, so the
|
|
14
|
+
# choice of fuzzy semantics is not only about logic: it decides which
|
|
15
|
+
# truth values receive gradients, and how large they are.
|
|
16
|
+
|
|
17
|
+
require "ltn"
|
|
18
|
+
|
|
19
|
+
def show(label, value)
|
|
20
|
+
puts "#{label.ljust(44)} #{value.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
puts "== Operators"
|
|
24
|
+
# Operators work directly on tensors of truth values.
|
|
25
|
+
x1 = Torch.tensor(0.4)
|
|
26
|
+
x2 = Torch.tensor(0.7)
|
|
27
|
+
and_prod = LTN::FuzzyOps::AndProd.new(stable: false) # `stable` is explained below
|
|
28
|
+
and_luk = LTN::FuzzyOps::AndLuk.new
|
|
29
|
+
show("product: 0.4 and 0.7", and_prod.call(x1, x2))
|
|
30
|
+
show("Lukasiewicz: 0.4 and 0.7", and_luk.call(x1, x2))
|
|
31
|
+
|
|
32
|
+
xs = Torch.tensor([1.0, 1.0, 1.0, 0.5, 0.3, 0.2, 0.2, 0.1])
|
|
33
|
+
forall_min = LTN::FuzzyOps::AggregMin.new
|
|
34
|
+
forall_pme = LTN::FuzzyOps::AggregPMeanError.new(p: 4, stable: false)
|
|
35
|
+
show("min", forall_min.call(xs, dim: 0))
|
|
36
|
+
show("pMeanError, p = 4", forall_pme.call(xs, dim: 0))
|
|
37
|
+
|
|
38
|
+
puts "\n== Gradients"
|
|
39
|
+
# Lukasiewicz's conjunction has zero gradient whenever x1 + x2 <= 1: no
|
|
40
|
+
# learning signal at all for such operands.
|
|
41
|
+
x1 = Torch.tensor(0.3).requires_grad!(true)
|
|
42
|
+
x2 = Torch.tensor(0.5).requires_grad!(true)
|
|
43
|
+
y = and_luk.call(x1, x2)
|
|
44
|
+
y.backward
|
|
45
|
+
show("Lukasiewicz: 0.3 and 0.5, gradients", [y.item, x1.grad.item, x2.grad.item])
|
|
46
|
+
|
|
47
|
+
# The minimum only passes gradients to the smallest value: one example per
|
|
48
|
+
# step gets a learning signal.
|
|
49
|
+
xs = Torch.tensor([1.0, 1.0, 1.0, 0.5, 0.3, 0.2, 0.2, 0.1]).requires_grad!(true)
|
|
50
|
+
y = forall_min.call(xs, dim: 0)
|
|
51
|
+
y.backward
|
|
52
|
+
show("min, gradients", [y.item, xs.grad.to_a])
|
|
53
|
+
|
|
54
|
+
# pMeanError passes gradients to every value, more to the ones further from
|
|
55
|
+
# the truth. But with all values at 1, its gradient is 0/0: NaN.
|
|
56
|
+
xs = Torch.tensor([1.0, 1.0, 1.0]).requires_grad!(true)
|
|
57
|
+
y = forall_pme.call(xs, dim: 0, p: 4)
|
|
58
|
+
y.backward
|
|
59
|
+
show("pMeanError of 1s, gradients", [y.item, xs.grad.to_a])
|
|
60
|
+
|
|
61
|
+
puts "\n== Stable operators"
|
|
62
|
+
# The stable variants (the default) first project the truth values slightly
|
|
63
|
+
# into (0, 1] or [0, 1) (LTN::FuzzyOps.pi_0 and pi_1, with epsilon 1e-4),
|
|
64
|
+
# keeping their results and gradients finite.
|
|
65
|
+
xs = Torch.tensor([1.0, 1.0, 1.0]).requires_grad!(true)
|
|
66
|
+
y = forall_pme.call(xs, dim: 0, p: 4, stable: true)
|
|
67
|
+
y.backward
|
|
68
|
+
show("stable pMeanError of 1s, gradients", [y.item, xs.grad.to_a])
|
|
69
|
+
|
|
70
|
+
# p trades off between a mean (p = 1, every value counts equally) and the
|
|
71
|
+
# minimum (large p, the worst values dominate): larger p focuses the
|
|
72
|
+
# gradients on the outliers.
|
|
73
|
+
[4, 20].each do |p|
|
|
74
|
+
xs = Torch.tensor([1.0, 1.0, 1.0, 0.5, 0.3, 0.2, 0.2, 0.1]).requires_grad!(true)
|
|
75
|
+
y = forall_pme.call(xs, dim: 0, p: p)
|
|
76
|
+
y.backward
|
|
77
|
+
show("pMeanError, p = #{p}, gradients", [y.item.round(4), xs.grad.to_a.map { |g| g.round(4) }])
|
|
78
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Released under the MIT License.
|
|
5
|
+
# Copyright, 2026, by Mauro Tortonesi.
|
|
6
|
+
# Ported from LTNtorch's tutorials/3-knowledgebase-and-learning.ipynb, copyright 2021-2024 by Tommaso Carraro;
|
|
7
|
+
# see the notebook for the full discussion.
|
|
8
|
+
#
|
|
9
|
+
# Tutorial 3: learning from a knowledge base.
|
|
10
|
+
#
|
|
11
|
+
# bundle exec ruby tutorials/3_knowledgebase_and_learning.rb
|
|
12
|
+
#
|
|
13
|
+
# The learnable parameters of LTN are those of the groundings of its
|
|
14
|
+
# symbols. Training maximizes the satisfaction of the knowledge base, i.e.
|
|
15
|
+
# the aggregated truth value of its axioms (here with SatAgg), by gradient
|
|
16
|
+
# descent on the loss 1 - satisfaction.
|
|
17
|
+
#
|
|
18
|
+
# The task: two classes A and B of points in [0, 4]^2, of which only one
|
|
19
|
+
# example each is known, a and b. The knowledge base states that a is an A,
|
|
20
|
+
# b is a B, and that similar points belong to the same classes:
|
|
21
|
+
#
|
|
22
|
+
# C(a, l_a)
|
|
23
|
+
# C(b, l_b)
|
|
24
|
+
# forall x1, x2, l: Sim(x1, x2) -> (C(x1, l) <-> C(x2, l))
|
|
25
|
+
|
|
26
|
+
require "ltn"
|
|
27
|
+
|
|
28
|
+
EPOCHS = Integer(ENV.fetch("EPOCHS", 2000))
|
|
29
|
+
BATCH_EPOCHS = Integer(ENV.fetch("BATCH_EPOCHS", 100))
|
|
30
|
+
|
|
31
|
+
Torch.manual_seed(2020)
|
|
32
|
+
|
|
33
|
+
# C(x, l): the probability, from a softmax over the two classes, that point x
|
|
34
|
+
# has class l (one-hot).
|
|
35
|
+
class ModelC < Torch::NN::Module
|
|
36
|
+
def initialize
|
|
37
|
+
super
|
|
38
|
+
@elu = Torch::NN::ELU.new
|
|
39
|
+
@softmax = Torch::NN::Softmax.new(dim: 1)
|
|
40
|
+
@dense1 = Torch::NN::Linear.new(2, 5)
|
|
41
|
+
@dense2 = Torch::NN::Linear.new(5, 5)
|
|
42
|
+
@dense3 = Torch::NN::Linear.new(5, 2)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def forward(x, l)
|
|
46
|
+
x = @elu.call(@dense1.call(x))
|
|
47
|
+
x = @elu.call(@dense2.call(x))
|
|
48
|
+
Torch.sum(@softmax.call(@dense3.call(x)) * l, dim: 1)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
points = Torch.tensor([[0.4, 0.3], [1.2, 0.3], [2.2, 1.3], [1.7, 1.0], [0.5, 0.5], [0.3, 1.5], [1.3, 1.1],
|
|
53
|
+
[0.9, 1.7], [3.4, 3.3], [3.2, 3.3], [3.2, 2.3], [2.7, 2.0], [3.5, 3.5], [3.3, 2.5], [3.3, 1.1], [1.9, 3.7],
|
|
54
|
+
[1.3, 3.5], [3.3, 1.1], [3.9, 3.7]])
|
|
55
|
+
|
|
56
|
+
c = LTN::Predicate.new(ModelC.new)
|
|
57
|
+
# Sim(u, v) = exp(-||u - v||): a similarity, not learned.
|
|
58
|
+
sim = LTN::Predicate.new { |u, v| Torch.exp(Torch.neg(Torch.sqrt(Torch.sum(Torch.square(u - v), dim: 1)))) }
|
|
59
|
+
|
|
60
|
+
x1 = LTN::Variable.new("x1", points)
|
|
61
|
+
x2 = LTN::Variable.new("x2", points)
|
|
62
|
+
a = LTN::Constant.new(Torch.tensor([3.3, 2.5]))
|
|
63
|
+
b = LTN::Constant.new(Torch.tensor([1.3, 1.1]))
|
|
64
|
+
l_a = LTN::Constant.new(Torch.tensor([1, 0]))
|
|
65
|
+
l_b = LTN::Constant.new(Torch.tensor([0, 1]))
|
|
66
|
+
l = LTN::Variable.new("l", Torch.tensor([[1, 0], [0, 1]]))
|
|
67
|
+
|
|
68
|
+
implies = LTN::Connective.new(LTN::FuzzyOps::ImpliesReichenbach.new)
|
|
69
|
+
equiv = LTN::Connective.new(LTN::FuzzyOps::Equiv.new(LTN::FuzzyOps::AndProd.new, LTN::FuzzyOps::ImpliesReichenbach.new))
|
|
70
|
+
forall = LTN::Quantifier.new(LTN::FuzzyOps::AggregPMeanError.new(p: 2), :forall)
|
|
71
|
+
sat_agg = LTN::FuzzyOps::SatAgg.new # pMeanError by default
|
|
72
|
+
|
|
73
|
+
puts "== Learning"
|
|
74
|
+
optimizer = Torch::Optim::Adam.new(c.parameters, lr: 0.001)
|
|
75
|
+
loss = nil
|
|
76
|
+
EPOCHS.times do |epoch|
|
|
77
|
+
optimizer.zero_grad
|
|
78
|
+
loss = 1.0 - sat_agg.call(
|
|
79
|
+
c.call(a, l_a),
|
|
80
|
+
c.call(b, l_b),
|
|
81
|
+
forall.call([x1, x2, l], implies.call(sim.call(x1, x2), equiv.call(c.call(x1, l), c.call(x2, l))))
|
|
82
|
+
)
|
|
83
|
+
loss.backward
|
|
84
|
+
optimizer.step
|
|
85
|
+
puts format("Epoch %d: Sat Level %.3f", epoch, 1 - loss.item) if (epoch % 200).zero?
|
|
86
|
+
end
|
|
87
|
+
puts format("Training finished with Sat Level %.3f", 1 - loss.item)
|
|
88
|
+
|
|
89
|
+
puts "\nC(x, l_a) for each point (a = [3.3, 2.5], b = [1.3, 1.1]):"
|
|
90
|
+
Torch.no_grad do
|
|
91
|
+
c.call(x1, l_a).value.to_a.each_with_index do |truth, i|
|
|
92
|
+
puts format(" [%.1f, %.1f] %.2f", *points[i].to_a, truth)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
puts "\n== Learning in mini-batches"
|
|
97
|
+
# With many individuals, a variable can be grounded with a mini-batch of
|
|
98
|
+
# them at each step, like any other training data. Here: 10,000 points of
|
|
99
|
+
# [0, 4]^2, with a = [3, 3] and b = [1, 1] among them.
|
|
100
|
+
points = Torch.rand(10_000, 2) * 4
|
|
101
|
+
points[-1] = Torch.tensor([3.0, 3.0])
|
|
102
|
+
points[-2] = Torch.tensor([1.0, 1.0])
|
|
103
|
+
a = LTN::Constant.new(Torch.tensor([3.0, 3.0]))
|
|
104
|
+
b = LTN::Constant.new(Torch.tensor([1.0, 1.0]))
|
|
105
|
+
|
|
106
|
+
c = LTN::Predicate.new(ModelC.new) # a new, untrained C
|
|
107
|
+
optimizer = Torch::Optim::Adam.new(c.parameters, lr: 0.001)
|
|
108
|
+
BATCH_EPOCHS.times do |epoch|
|
|
109
|
+
Torch.randperm(points.shape[0]).split(512).each do |indices|
|
|
110
|
+
batch = points[indices]
|
|
111
|
+
x1 = LTN::Variable.new("x1", batch)
|
|
112
|
+
x2 = LTN::Variable.new("x2", batch)
|
|
113
|
+
optimizer.zero_grad
|
|
114
|
+
loss = 1.0 - sat_agg.call(
|
|
115
|
+
c.call(a, l_a),
|
|
116
|
+
c.call(b, l_b),
|
|
117
|
+
forall.call([x1, x2, l], implies.call(sim.call(x1, x2), equiv.call(c.call(x1, l), c.call(x2, l))))
|
|
118
|
+
)
|
|
119
|
+
loss.backward
|
|
120
|
+
optimizer.step
|
|
121
|
+
end
|
|
122
|
+
puts format("Epoch %d: Sat Level %.3f", epoch, 1 - loss.item) if (epoch % 10).zero?
|
|
123
|
+
end
|
|
124
|
+
puts format("Training finished with Sat Level %.3f", 1 - loss.item)
|