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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +34 -0
  3. data/CONTRIBUTING.md +66 -0
  4. data/changelog.md +68 -0
  5. data/code_of_conduct.md +84 -0
  6. data/examples/1_binary_classification.rb +108 -0
  7. data/examples/2_multi_class_single_label_classification.rb +130 -0
  8. data/examples/3_multi_class_multi_label_classification.rb +158 -0
  9. data/examples/4_semi_supervised_pattern_recognition.rb +249 -0
  10. data/examples/5_regression.rb +103 -0
  11. data/examples/6_clustering.rb +126 -0
  12. data/examples/7_learning_embeddings_with_ltn.rb +166 -0
  13. data/examples/datasets/crabs.dat +201 -0
  14. data/examples/datasets/iris_test.csv +31 -0
  15. data/examples/datasets/iris_training.csv +121 -0
  16. data/examples/datasets/readme.md +9 -0
  17. data/examples/datasets/real-estate.csv +415 -0
  18. data/examples/support/example.rb +92 -0
  19. data/examples/support/mnist.rb +77 -0
  20. data/lib/ltn/connective.rb +56 -0
  21. data/lib/ltn/constant.rb +30 -0
  22. data/lib/ltn/diag.rb +54 -0
  23. data/lib/ltn/function.rb +28 -0
  24. data/lib/ltn/fuzzy_ops.rb +428 -0
  25. data/lib/ltn/lambda_model.rb +24 -0
  26. data/lib/ltn/ltn_object.rb +47 -0
  27. data/lib/ltn/model_wrapper.rb +72 -0
  28. data/lib/ltn/predicate.rb +38 -0
  29. data/lib/ltn/process_ltn_objects.rb +58 -0
  30. data/lib/ltn/quantifier.rb +176 -0
  31. data/lib/ltn/variable.rb +49 -0
  32. data/lib/ltn/version.rb +8 -0
  33. data/lib/ltn.rb +45 -0
  34. data/license.md +22 -0
  35. data/readme.md +166 -0
  36. data/sig/ltn.rbs +207 -0
  37. data/tutorials/1_grounding_non_logical_symbols.rb +146 -0
  38. data/tutorials/2_grounding_connectives.rb +126 -0
  39. data/tutorials/2b_operators_and_gradients.rb +78 -0
  40. data/tutorials/3_knowledgebase_and_learning.rb +124 -0
  41. 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)