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
@@ -0,0 +1,249 @@
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 examples/4-semi-supervised_pattern_recognition.ipynb, copyright 2021-2024 by
7
+ # Tommaso Carraro.
8
+
9
+ # Semi-supervised pattern recognition: MNIST digit addition, from the LTN
10
+ # paper. The training data are pairs of MNIST images labeled with the sum of
11
+ # their digits only, never with the digits themselves; a digit classifier
12
+ # Digit(x, d) is learned from the knowledge base
13
+ #
14
+ # forall diag(x, y, n): exists d1, d2 with d1 + d2 = n: Digit(x, d1) and Digit(y, d2)
15
+ #
16
+ # In the multi-digit task, each operand is a two-digit number (two images),
17
+ # and the guard becomes 10 d1 + d2 + 10 d3 + d4 = n.
18
+ #
19
+ # Like LTNtorch's notebook, the models initialize their weights with
20
+ # Kaiming/Xavier and their biases with N(0, 1) (#init_weights). With that
21
+ # initialization, single-digit addition reaches about 48% test accuracy
22
+ # after 20 epochs, in LTNtorch as here (checked against LTNtorch with the
23
+ # same settings: 46.5% vs 46.4% after one epoch, 48.4% vs 48.3% after 20).
24
+ # With PyTorch's default initialization (skipping the init_weights calls),
25
+ # LTNtorch reaches about 98%, as reported in the LTN paper: pass
26
+ # `--default-init` to train that way.
27
+ #
28
+ # The first run downloads MNIST (about 11 MB) into examples/datasets/MNIST.
29
+ # Training follows LTNtorch's settings (20 epochs on 30,000 additions), which
30
+ # takes a while on a CPU; set EPOCHS to train for fewer epochs, and pass
31
+ # `multi` to run the multi-digit task instead of the single-digit one.
32
+ #
33
+ # bundle exec ruby examples/4_semi_supervised_pattern_recognition.rb [single|multi] [--default-init]
34
+
35
+ require_relative "support/example"
36
+ require_relative "support/mnist"
37
+
38
+ module LTNExamples
39
+ class SemiSupervisedPatternRecognition
40
+ # Convolutional embeddings of MNIST images.
41
+ class MNISTConv < Torch::NN::Module
42
+ # @parameter init_weights [Boolean] whether to initialize the weights as
43
+ # LTNtorch's example does (see #init_weights), or keep PyTorch's defaults.
44
+ def initialize(conv_channels_sizes: [1, 6, 16], kernel_sizes: [5, 5], linear_layers_sizes: [256, 100],
45
+ init_weights: true)
46
+ super()
47
+ @conv_layers = Torch::NN::ModuleList.new(
48
+ conv_channels_sizes.each_cons(2).zip(kernel_sizes).map { |(input, output), kernel| Torch::NN::Conv2d.new(input, output, kernel) }
49
+ )
50
+ @relu = Torch::NN::ReLU.new
51
+ @tanh = Torch::NN::Tanh.new
52
+ @maxpool = Torch::NN::MaxPool2d.new([2, 2])
53
+ @linear_layers = Torch::NN::ModuleList.new(
54
+ linear_layers_sizes.each_cons(2).map { |input, output| Torch::NN::Linear.new(input, output) }
55
+ )
56
+ @batch_norm_layers = Torch::NN::ModuleList.new(
57
+ linear_layers_sizes.drop(1).map { |size| Torch::NN::BatchNorm1d.new(size) }
58
+ )
59
+ self.init_weights if init_weights
60
+ end
61
+
62
+ def forward(x)
63
+ @conv_layers.each { |conv| x = @maxpool.call(@relu.call(conv.call(x))) }
64
+ x = Torch.flatten(x, start_dim: 1)
65
+ @linear_layers.to_a.zip(@batch_norm_layers.to_a).each do |linear, batch_norm|
66
+ x = @tanh.call(batch_norm.call(linear.call(x)))
67
+ end
68
+ x
69
+ end
70
+
71
+ # Kaiming (convolutions) and Xavier (linear layers) weights, and N(0, 1) biases.
72
+ def init_weights
73
+ @conv_layers.each do |layer|
74
+ Torch::NN::Init.kaiming_uniform!(layer.weight)
75
+ Torch::NN::Init.normal!(layer.bias)
76
+ end
77
+ @linear_layers.each do |layer|
78
+ Torch::NN::Init.xavier_uniform!(layer.weight)
79
+ Torch::NN::Init.normal!(layer.bias)
80
+ end
81
+ end
82
+ end
83
+
84
+ # Returns the logits of the ten digits for an MNIST image.
85
+ class SingleDigitClassifier < Torch::NN::Module
86
+ # @parameter init_weights [Boolean] as for MNISTConv.
87
+ def initialize(layers_sizes: [100, 84, 10], init_weights: true)
88
+ super()
89
+ @mnistconv = MNISTConv.new(init_weights: init_weights)
90
+ @tanh = Torch::NN::Tanh.new
91
+ @linear_layers = Torch::NN::ModuleList.new(
92
+ layers_sizes.each_cons(2).map { |input, output| Torch::NN::Linear.new(input, output) }
93
+ )
94
+ @batch_norm_layers = Torch::NN::ModuleList.new(
95
+ layers_sizes.drop(1).map { |size| Torch::NN::BatchNorm1d.new(size) }
96
+ )
97
+ self.init_weights if init_weights
98
+ end
99
+
100
+ def forward(x)
101
+ x = @mnistconv.call(x)
102
+ linear_layers = @linear_layers.to_a
103
+ linear_layers[0...-1].each_with_index do |layer, i|
104
+ x = @tanh.call(@batch_norm_layers.to_a[i].call(layer.call(x)))
105
+ end
106
+ linear_layers.last.call(x)
107
+ end
108
+
109
+ # Xavier weights and N(0, 1) biases for the linear layers.
110
+ def init_weights
111
+ @linear_layers.each do |layer|
112
+ Torch::NN::Init.xavier_uniform!(layer.weight)
113
+ Torch::NN::Init.normal!(layer.bias)
114
+ end
115
+ end
116
+ end
117
+
118
+ # The truth value of Digit(x, d): the softmax probability of digit d for image x.
119
+ class LogitsToPredicate < Torch::NN::Module
120
+ def initialize(logits_model)
121
+ super()
122
+ @logits_model = logits_model
123
+ @softmax = Torch::NN::Softmax.new(dim: 1)
124
+ end
125
+
126
+ def forward(x, d)
127
+ Torch.gather(@softmax.call(@logits_model.call(x)), 1, d)
128
+ end
129
+ end
130
+
131
+ TASKS = {
132
+ single: {operands: 2, train_examples: 30_000, test_examples: 5_000},
133
+ multi: {operands: 4, train_examples: 15_000, test_examples: 2_500}
134
+ }.freeze
135
+
136
+ # @parameter task [Symbol] :single (one-digit operands) or :multi (two-digit operands).
137
+ # @parameter data [Hash, nil] {train: [images, labels], test: [images, labels]}, with images
138
+ # as uint8 n x 28 x 28 tensors; by default, MNIST.
139
+ # @parameter init_weights [Boolean] true to initialize the classifier as LTNtorch's example
140
+ # does (about 48% test accuracy), false for PyTorch's defaults (about 98%).
141
+ def initialize(task: :single, seed: 2020, output: $stdout, data: nil, init_weights: true)
142
+ @task = task
143
+ @output = output
144
+ LTNExamples.seed(seed)
145
+
146
+ data ||= {train: MNIST.load(:train), test: MNIST.load(:test)}
147
+ settings = TASKS.fetch(task)
148
+ @train = additions(*data[:train], settings[:operands], settings[:train_examples])
149
+ @test = additions(*data[:test], settings[:operands], settings[:test_examples])
150
+
151
+ @digits = (1..settings[:operands]).map { |i| LTN::Variable.new("d_#{i}", Torch.arange(10)) }
152
+ @cnn = SingleDigitClassifier.new(init_weights: init_weights)
153
+ @digit = LTN::Predicate.new(LogitsToPredicate.new(@cnn))
154
+ @and = LTN::Connective.new(LTN::FuzzyOps::AndProd.new)
155
+ @exists = LTN::Quantifier.new(LTN::FuzzyOps::AggregPMean.new(p: 2), :exists)
156
+ @forall = LTN::Quantifier.new(LTN::FuzzyOps::AggregPMeanError.new(p: 2), :forall)
157
+ end
158
+
159
+ def run(epochs: 20, batch_size: 32)
160
+ optimizer = Torch::Optim::Adam.new(@digit.parameters, lr: 0.001)
161
+ metrics = nil
162
+
163
+ epochs.times do |epoch|
164
+ # The exponent of the existential quantifier grows during training.
165
+ p = case epoch
166
+ when 0...4 then 1
167
+ when 4...8 then 2
168
+ when 8...12 then 4
169
+ else 6
170
+ end
171
+
172
+ train = LTNExamples.batches(@train, batch_size: batch_size, shuffle: true).map do |images, labels|
173
+ optimizer.zero_grad
174
+ sat = satisfaction(images, labels, p)
175
+ loss = 1.0 - sat
176
+ loss.backward
177
+ optimizer.step
178
+ [loss.item, sat.item, accuracy(images, labels)]
179
+ end
180
+ test = LTNExamples.batches(@test, batch_size: batch_size, shuffle: false).map do |images, labels|
181
+ Torch.no_grad do
182
+ sat = satisfaction(images, labels, p)
183
+ [1.0 - sat.item, sat.item, accuracy(images, labels)]
184
+ end
185
+ end
186
+
187
+ train_loss, train_sat, train_acc = train.transpose.map { |values| values.sum / values.size }
188
+ test_loss, test_sat, test_acc = test.transpose.map { |values| values.sum / values.size }
189
+ metrics = {train_sat: train_sat, test_sat: test_sat, train_accuracy: train_acc, test_accuracy: test_acc}
190
+ @output.puts format(" epoch %d | Train loss %.4f | Train Sat %.4f | Train Acc %.4f | Test loss %.4f | " \
191
+ "Test Sat %.4f | Test Acc %.4f", epoch, train_loss, train_sat, train_acc, test_loss, test_sat, test_acc)
192
+ end
193
+
194
+ metrics
195
+ end
196
+
197
+ def satisfaction(images, labels, p)
198
+ operands = images.shape[1].times.map { |i| LTN::Variable.new("x#{i + 1}", images[0.., i]) }
199
+ z = LTN::Variable.new("z", labels)
200
+ # diag must come first: it changes the labels of the variables, so the
201
+ # formulas built from them below pair the operands with their sum.
202
+ quantified = LTN.diag(*operands, z)
203
+ digits = operands.zip(@digits).map { |x, d| @digit.call(x, d) }
204
+ @forall.call(
205
+ quantified,
206
+ @exists.call(@digits, digits.reduce { |a, b| @and.call(a, b) },
207
+ cond_vars: @digits + [z], cond_fn: ->(*ds, n) { number(ds.map(&:value)).eq(n.value) }, p: p)
208
+ ).value
209
+ end
210
+
211
+ # Accuracy of the predicted sums.
212
+ def accuracy(images, labels)
213
+ Torch.no_grad do
214
+ predictions = images.shape[1].times.map { |i| Torch.argmax(@cnn.call(images[0.., i]), dim: 1) }
215
+ LTNExamples.accuracy(number(predictions), labels)
216
+ end
217
+ end
218
+
219
+ private
220
+
221
+ # The sum the digits stand for: d1 + d2, or (10 d1 + d2) + (10 d3 + d4).
222
+ def number(digits)
223
+ (@task == :single) ? digits[0] + digits[1] : digits[0] * 10 + digits[1] + digits[2] * 10 + digits[3]
224
+ end
225
+
226
+ # Groups consecutive slices of the images into additions, as LTNtorch does:
227
+ # operand i of example j is image i * examples + j. With fewer images than
228
+ # needed, makes as many additions as they allow.
229
+ def additions(images, labels, operands, examples)
230
+ examples = [examples, images.shape[0] / operands].min
231
+ images = images.float / 255.0
232
+ slices = (0...operands).map { |i| (i * examples)...((i + 1) * examples) }
233
+ inputs = Torch.stack(slices.map { |range| images[range].unsqueeze(1) }, dim: 1)
234
+ digits = slices.map { |range| labels[range] }
235
+ [inputs, number(digits)]
236
+ end
237
+ end
238
+ end
239
+
240
+ if File.expand_path($PROGRAM_NAME) == File.expand_path(__FILE__)
241
+ unknown = ARGV - %w[single multi --default-init]
242
+ abort "Unknown arguments: #{unknown.join(" ")}\nUsage: #{$PROGRAM_NAME} [single|multi] [--default-init]" if unknown.any?
243
+
244
+ example = LTNExamples::SemiSupervisedPatternRecognition.new(
245
+ task: ARGV.include?("multi") ? :multi : :single,
246
+ init_weights: !ARGV.include?("--default-init")
247
+ )
248
+ LTNExamples.main(example, __FILE__)
249
+ end
@@ -0,0 +1,103 @@
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 examples/5-regression.ipynb, copyright 2021-2024 by Tommaso Carraro.
7
+
8
+ # Regression on the Real estate valuation dataset: learn a function f(x)
9
+ # predicting the price per unit area of a house from its features, from the
10
+ # knowledge base
11
+ #
12
+ # forall diag(x, y): Eq(f(x), y)
13
+ #
14
+ # where Eq(u, v) = exp(-alpha * ||u - v||) is a smooth equality, and diag
15
+ # pairs each house x with its own price y (diagonal quantification).
16
+ #
17
+ # bundle exec ruby examples/5_regression.rb
18
+
19
+ require_relative "support/example"
20
+
21
+ module LTNExamples
22
+ class Regression
23
+ # A multi-layer perceptron with a linear output.
24
+ class MLP < Torch::NN::Module
25
+ def initialize(layer_sizes = [6, 8, 8, 1])
26
+ super()
27
+ @elu = Torch::NN::ELU.new
28
+ @linear_layers = Torch::NN::ModuleList.new(
29
+ layer_sizes.each_cons(2).map { |input, output| Torch::NN::Linear.new(input, output) }
30
+ )
31
+ end
32
+
33
+ def forward(x)
34
+ @linear_layers.to_a[0...-1].each { |layer| x = @elu.call(layer.call(x)) }
35
+ @linear_layers.to_a.last.call(x)
36
+ end
37
+ end
38
+
39
+ FEATURES = ["X1 transaction date", "X2 house age", "X3 distance to the nearest MRT station",
40
+ "X4 number of convenience stores", "X5 latitude", "X6 longitude"].freeze
41
+ TARGET = "Y house price of unit area"
42
+ ALPHA = 0.05
43
+
44
+ def initialize(seed: 2020, output: $stdout)
45
+ @output = output
46
+ LTNExamples.seed(seed)
47
+
48
+ header, rows = LTNExamples.read_table("real-estate.csv")
49
+ rows = rows.values_at(*Torch.randperm(rows.size).to_a) # shuffle, as LTNtorch does
50
+ x = Torch.tensor(rows.map { |row| FEATURES.map { |column| Float(row[header.index(column)]) } })
51
+ y = Torch.tensor(rows.map { |row| [Float(row[header.index(TARGET)])] })
52
+ @train = [x[0...330], y[0...330]]
53
+ @test = [x[330..], y[330..]]
54
+
55
+ @f = LTN::Function.new(MLP.new)
56
+ @eq = LTN::Predicate.new { |u, v| Torch.exp(Torch.sqrt(Torch.sum(Torch.square(u - v), dim: 1)) * -ALPHA) }
57
+ @forall = LTN::Quantifier.new(LTN::FuzzyOps::AggregPMeanError.new(p: 2), :forall)
58
+ end
59
+
60
+ def run(epochs: 500, log_every: 50)
61
+ optimizer = Torch::Optim::Adam.new(@f.parameters, lr: 0.0005)
62
+
63
+ epochs.times do |epoch|
64
+ train_loss = 0.0
65
+ batches = LTNExamples.batches(@train, batch_size: 64, shuffle: true)
66
+ batches.each do |x_data, y_data|
67
+ optimizer.zero_grad
68
+ loss = 1.0 - satisfaction(x_data, y_data)
69
+ loss.backward
70
+ optimizer.step
71
+ train_loss += loss.item
72
+ end
73
+ next unless (epoch % log_every).zero?
74
+
75
+ @output.puts format(" epoch %d | loss %.4f | Train Sat %.3f | Test Sat %.3f | Train RMSE %.3f | Test RMSE %.3f",
76
+ epoch, train_loss / batches.size, sat_level(*@train), sat_level(*@test), rmse(*@train), rmse(*@test))
77
+ end
78
+
79
+ {train_sat: sat_level(*@train), test_sat: sat_level(*@test), train_rmse: rmse(*@train), test_rmse: rmse(*@test)}
80
+ end
81
+
82
+ def satisfaction(x_data, y_data)
83
+ x = LTN::Variable.new("x", x_data)
84
+ y = LTN::Variable.new("y", y_data)
85
+ @forall.call(LTN.diag(x, y), @eq.call(@f.call(x), y)).value
86
+ end
87
+
88
+ # LTNtorch averages over batches of 64; the whole set is a single batch for the test set.
89
+ def sat_level(x_data, y_data)
90
+ batches = LTNExamples.batches([x_data, y_data], batch_size: 64, shuffle: false)
91
+ Torch.no_grad { batches.sum { |x, y| satisfaction(x, y).item } / batches.size }
92
+ end
93
+
94
+ def rmse(x_data, y_data)
95
+ batches = LTNExamples.batches([x_data, y_data], batch_size: 64, shuffle: false)
96
+ Torch.no_grad do
97
+ batches.sum { |x, y| Torch.sqrt(Torch.mean(Torch.square(@f.model.call(x) - y))).item } / batches.size
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ LTNExamples.main(LTNExamples::Regression.new, __FILE__)
@@ -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 examples/6-clustering.ipynb, copyright 2021-2024 by Tommaso Carraro.
7
+
8
+ # Clustering, without labels: learn a predicate C(x, c), "point x belongs to
9
+ # cluster c", for four clusters of points, from the knowledge base
10
+ #
11
+ # forall x: exists c: C(x, c) every point is in some cluster
12
+ # forall c: exists x: C(x, c) no cluster is empty
13
+ # forall c, x, y with dist(x, y) < 0.2: C(x, c) <-> C(y, c) close points share clusters
14
+ # forall c, x, y with dist(x, y) > 1.0: not (C(x, c) and C(y, c)) distant points do not
15
+ #
16
+ # The last two axioms use guarded quantification.
17
+ #
18
+ # bundle exec ruby examples/6_clustering.rb
19
+
20
+ require_relative "support/example"
21
+
22
+ module LTNExamples
23
+ class Clustering
24
+ # The probability, from a softmax over the clusters, that point x belongs
25
+ # to cluster c (one-hot).
26
+ class MLP < Torch::NN::Module
27
+ def initialize(layer_sizes = [2, 16, 16, 16, 4])
28
+ super()
29
+ @elu = Torch::NN::ELU.new
30
+ @softmax = Torch::NN::Softmax.new(dim: 1)
31
+ @linear_layers = Torch::NN::ModuleList.new(
32
+ layer_sizes.each_cons(2).map { |input, output| Torch::NN::Linear.new(input, output) }
33
+ )
34
+ end
35
+
36
+ def forward(x, c)
37
+ @linear_layers.to_a[0...-1].each { |layer| x = @elu.call(layer.call(x)) }
38
+ Torch.sum(@softmax.call(@linear_layers.to_a.last.call(x)) * c, dim: 1)
39
+ end
40
+ end
41
+
42
+ CLUSTERS = 4
43
+ POINTS_PER_CLUSTER = 50
44
+ CLOSE_THRESHOLD = 0.2
45
+ DISTANT_THRESHOLD = 1.0
46
+ MARGIN = 0.2
47
+
48
+ attr_reader :data, :c
49
+
50
+ def initialize(seed: 2020, output: $stdout)
51
+ @output = output
52
+ LTNExamples.seed(seed)
53
+
54
+ # One cluster per quadrant of [-1, 1]^2, its mean drawn uniformly within
55
+ # the quadrant (minus a margin), its points normally around the mean
56
+ # with standard deviation 0.1.
57
+ quadrants = [[-1, -1], [0, -1], [-1, 0], [0, 0]]
58
+ @data = Torch.cat(quadrants.map do |low|
59
+ low = Torch.tensor(low, dtype: :float32) + MARGIN
60
+ mean = low + Torch.rand(2) * (1 - 2 * MARGIN)
61
+ mean + Torch.randn(POINTS_PER_CLUSTER, 2) * 0.1
62
+ end)
63
+
64
+ @cluster = LTN::Predicate.new(MLP.new)
65
+ @c = LTN::Variable.new("c", Torch.eye(CLUSTERS))
66
+ @x = LTN::Variable.new("x", @data)
67
+ @y = LTN::Variable.new("y", @data)
68
+ @th_close = LTN::Constant.new(Torch.tensor(CLOSE_THRESHOLD))
69
+ @th_distant = LTN::Constant.new(Torch.tensor(DISTANT_THRESHOLD))
70
+
71
+ @and = LTN::Connective.new(LTN::FuzzyOps::AndProd.new)
72
+ @not = LTN::Connective.new(LTN::FuzzyOps::NotStandard.new)
73
+ @equiv = LTN::Connective.new(LTN::FuzzyOps::Equiv.new(LTN::FuzzyOps::AndProd.new,
74
+ LTN::FuzzyOps::ImpliesReichenbach.new))
75
+ @exists = LTN::Quantifier.new(LTN::FuzzyOps::AggregPMean.new(p: 1), :exists)
76
+ @forall = LTN::Quantifier.new(LTN::FuzzyOps::AggregPMeanError.new(p: 4), :forall)
77
+ @sat_agg = LTN::FuzzyOps::SatAgg.new
78
+ end
79
+
80
+ def run(epochs: 1000, log_every: 100)
81
+ optimizer = Torch::Optim::Adam.new(@cluster.parameters, lr: 0.001)
82
+ sat = nil
83
+
84
+ epochs.times do |epoch|
85
+ optimizer.zero_grad
86
+ sat = satisfaction(p_exists: (epoch <= 100) ? 1 : 6)
87
+ loss = 1.0 - sat
88
+ loss.backward
89
+ optimizer.step
90
+ @output.puts format(" epoch %d | loss %.4f | Train Sat %.3f", epoch, loss.item, sat.item) if (epoch % log_every).zero?
91
+ end
92
+
93
+ {train_sat: sat.item, clusters_used: assignments.to_a.uniq.size}
94
+ end
95
+
96
+ def satisfaction(p_exists:)
97
+ dist = ->(u, v) { Torch.norm(u - v, 2, dim: 1).unsqueeze(1) }
98
+ @sat_agg.call(
99
+ @forall.call(@x, @exists.call(@c, @cluster.call(@x, @c), p: p_exists)),
100
+ @forall.call(@c, @exists.call(@x, @cluster.call(@x, @c), p: p_exists)),
101
+ @forall.call([@c, @x, @y], @equiv.call(@cluster.call(@x, @c), @cluster.call(@y, @c)),
102
+ cond_vars: [@x, @y]) { |x, y| dist.call(x.value, y.value).lt(@th_close.value) },
103
+ @forall.call([@c, @x, @y], @not.call(@and.call(@cluster.call(@x, @c), @cluster.call(@y, @c))),
104
+ cond_vars: [@x, @y]) { |x, y| dist.call(x.value, y.value).gt(@th_distant.value) }
105
+ )
106
+ end
107
+
108
+ # The most likely cluster of each point.
109
+ def assignments
110
+ Torch.no_grad do
111
+ probabilities = (0...CLUSTERS).map do |i|
112
+ @cluster.model.call(@data, Torch.eye(CLUSTERS)[i].unsqueeze(0).expand(@data.shape[0], CLUSTERS))
113
+ end
114
+ Torch.argmax(Torch.stack(probabilities, dim: 1), dim: 1)
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ LTNExamples.main(LTNExamples::Clustering.new, __FILE__) do |example|
121
+ markers = %w[o + x *]
122
+ assigned = example.assignments
123
+ series = markers.each_with_index.to_h { |marker, i| ["cluster #{i}", [marker, example.data[assigned.eq(i)]]] }
124
+ puts
125
+ puts LTNExamples.scatter(series, title: "Learned clusters", xlim: [-1.0, 1.0], ylim: [-1.0, 1.0])
126
+ end
@@ -0,0 +1,166 @@
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 examples/7-learning_embeddings_with_LTN.ipynb, copyright 2021-2024 by Tommaso Carraro.
7
+
8
+ # Learning embeddings: the "smokers, friends and cancer" problem. Fourteen
9
+ # people, in two groups (a-h and i-n), are grounded as trainable constants
10
+ # (embeddings) learned together with three predicates: Smokes(x),
11
+ # Cancer(x) and Friends(x, y). The knowledge base contains:
12
+ #
13
+ # - facts: some friendships, who smokes, and who has cancer (the latter only
14
+ # for the first group); all other friendships within a group are false;
15
+ # - rules: friendship is irreflexive and symmetric, everybody has a friend,
16
+ # friends of smokers smoke, smoking causes cancer, and not smoking
17
+ # prevents it.
18
+ #
19
+ # After training, the script queries two formulas the knowledge base does
20
+ # not state:
21
+ #
22
+ # phi1: forall p: Cancer(p) -> Smokes(p)
23
+ # phi2: forall p, q: (Cancer(p) or Cancer(q)) -> Friends(p, q)
24
+ #
25
+ # bundle exec ruby examples/7_learning_embeddings_with_ltn.rb
26
+
27
+ require_relative "support/example"
28
+
29
+ module LTNExamples
30
+ class LearningEmbeddings
31
+ # A multi-layer perceptron over the concatenation of its arguments.
32
+ class MLP < Torch::NN::Module
33
+ def initialize(layer_sizes)
34
+ super()
35
+ @elu = Torch::NN::ELU.new
36
+ @sigmoid = Torch::NN::Sigmoid.new
37
+ @linear_layers = Torch::NN::ModuleList.new(
38
+ layer_sizes.each_cons(2).map { |input, output| Torch::NN::Linear.new(input, output) }
39
+ )
40
+ end
41
+
42
+ def forward(*inputs)
43
+ x = (inputs.size == 1) ? inputs.first : Torch.cat(inputs, dim: 1)
44
+ @linear_layers.to_a[0...-1].each { |layer| x = @elu.call(layer.call(x)) }
45
+ @sigmoid.call(@linear_layers.to_a.last.call(x))
46
+ end
47
+ end
48
+
49
+ EMBEDDING_SIZE = 5
50
+ GROUP1 = ("a".."h").to_a.freeze
51
+ GROUP2 = ("i".."n").to_a.freeze
52
+ FRIENDS = [%w[a b], %w[a e], %w[a f], %w[a g], %w[b c], %w[c d], %w[e f], %w[g h],
53
+ %w[i j], %w[j m], %w[k l], %w[m n]].freeze
54
+ SMOKES = %w[a e f g j n].freeze
55
+ CANCER = %w[a e].freeze
56
+
57
+ attr_reader :people
58
+
59
+ def initialize(seed: 2020, output: $stdout)
60
+ @output = output
61
+ LTNExamples.seed(seed)
62
+
63
+ @people = (GROUP1 + GROUP2).to_h { |person| [person, LTN::Constant.new(Torch.rand(EMBEDDING_SIZE), trainable: true)] }
64
+
65
+ @cancer = LTN::Predicate.new(MLP.new([5, 16, 16, 1]))
66
+ @smokes = LTN::Predicate.new(MLP.new([5, 16, 16, 1]))
67
+ @friends = LTN::Predicate.new(MLP.new([10, 16, 16, 1]))
68
+
69
+ @and = LTN::Connective.new(LTN::FuzzyOps::AndProd.new)
70
+ @or = LTN::Connective.new(LTN::FuzzyOps::OrProbSum.new)
71
+ @not = LTN::Connective.new(LTN::FuzzyOps::NotStandard.new)
72
+ @implies = LTN::Connective.new(LTN::FuzzyOps::ImpliesReichenbach.new)
73
+ @exists = LTN::Quantifier.new(LTN::FuzzyOps::AggregPMean.new(p: 2), :exists)
74
+ @forall = LTN::Quantifier.new(LTN::FuzzyOps::AggregPMeanError.new(p: 2), :forall)
75
+ @sat_agg = LTN::FuzzyOps::SatAgg.new
76
+ end
77
+
78
+ def run(epochs: 1000, log_every: 20)
79
+ parameters = @smokes.parameters + @friends.parameters + @cancer.parameters + @people.values.map(&:value)
80
+ optimizer = Torch::Optim::Adam.new(parameters, lr: 0.001)
81
+ sat = nil
82
+
83
+ epochs.times do |epoch|
84
+ optimizer.zero_grad
85
+ sat = satisfaction(p_exists: (epoch <= 200) ? 1 : 6)
86
+ loss = 1.0 - sat
87
+ loss.backward
88
+ optimizer.step
89
+ next unless (epoch % log_every).zero?
90
+
91
+ @output.puts format(" epoch %d | loss %.4f | Train Sat %.3f | Phi1 Sat %.3f | Phi2 Sat %.3f",
92
+ epoch, loss.item, sat.item, phi1, phi2)
93
+ end
94
+
95
+ {train_sat: sat.item, phi1: phi1, phi2: phi2}
96
+ end
97
+
98
+ def satisfaction(p_exists:)
99
+ g = @people
100
+ # The embeddings change at each step, so the variables are rebuilt from them.
101
+ x = LTN::Variable.new("x", Torch.stack(g.values.map(&:value)))
102
+ y = LTN::Variable.new("y", Torch.stack(g.values.map(&:value)))
103
+ not_friends = [GROUP1, GROUP2].flat_map do |group|
104
+ group.combination(2).reject { |pair| FRIENDS.include?(pair) }
105
+ .map { |a, b| @not.call(@friends.call(g[a], g[b])) }
106
+ end
107
+
108
+ @sat_agg.call(
109
+ @sat_agg.call(*FRIENDS.map { |a, b| @friends.call(g[a], g[b]) }),
110
+ @sat_agg.call(*not_friends),
111
+ @sat_agg.call(*SMOKES.map { |a| @smokes.call(g[a]) }),
112
+ @sat_agg.call(*(g.keys - SMOKES).map { |a| @not.call(@smokes.call(g[a])) }),
113
+ @sat_agg.call(*CANCER.map { |a| @cancer.call(g[a]) }),
114
+ @sat_agg.call(*(GROUP1 - CANCER).map { |a| @not.call(@cancer.call(g[a])) }),
115
+ @forall.call(x, @not.call(@friends.call(x, x)), p: 5),
116
+ @forall.call([x, y], @implies.call(@friends.call(x, y), @friends.call(y, x)), p: 5),
117
+ @forall.call(x, @exists.call(y, @friends.call(x, y), p: p_exists)),
118
+ @forall.call([x, y], @implies.call(@and.call(@friends.call(x, y), @smokes.call(x)), @smokes.call(y))),
119
+ @forall.call(x, @implies.call(@smokes.call(x), @cancer.call(x))),
120
+ @forall.call(x, @implies.call(@not.call(@smokes.call(x)), @not.call(@cancer.call(x))))
121
+ )
122
+ end
123
+
124
+ def phi1
125
+ Torch.no_grad do
126
+ p = LTN::Variable.new("p", Torch.stack(@people.values.map(&:value)))
127
+ @forall.call(p, @implies.call(@cancer.call(p), @smokes.call(p)), p: 5).value.item
128
+ end
129
+ end
130
+
131
+ def phi2
132
+ Torch.no_grad do
133
+ p = LTN::Variable.new("p", Torch.stack(@people.values.map(&:value)))
134
+ q = LTN::Variable.new("q", Torch.stack(@people.values.map(&:value)))
135
+ @forall.call([p, q], @implies.call(@or.call(@cancer.call(p), @cancer.call(q)), @friends.call(p, q)), p: 5)
136
+ .value.item
137
+ end
138
+ end
139
+
140
+ # The learned truth values: Smokes and Cancer for each person, and Friends for each pair.
141
+ def truth_values
142
+ Torch.no_grad do
143
+ p = LTN::Variable.new("p", Torch.stack(@people.values.map(&:value)))
144
+ q = LTN::Variable.new("q", Torch.stack(@people.values.map(&:value)))
145
+ {smokes: @smokes.call(p).value, cancer: @cancer.call(p).value, friends: @friends.call(p, q).value}
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ LTNExamples.main(LTNExamples::LearningEmbeddings.new, __FILE__) do |example|
152
+ names = example.people.keys
153
+ values = example.truth_values
154
+ puts "\n Smokes Cancer (facts: smokes #{LTNExamples::LearningEmbeddings::SMOKES.join}, " \
155
+ "cancer #{LTNExamples::LearningEmbeddings::CANCER.join}, unknown for #{LTNExamples::LearningEmbeddings::GROUP2.join})"
156
+ names.each_with_index do |name, i|
157
+ puts format(" %s %.2f %.2f", name, values[:smokes][i].item, values[:cancer][i].item)
158
+ end
159
+ [LTNExamples::LearningEmbeddings::GROUP1, LTNExamples::LearningEmbeddings::GROUP2].each do |group|
160
+ puts "\nFriends(row, column):"
161
+ puts " " + group.map { |name| name.center(5) }.join
162
+ group.each do |row|
163
+ puts " #{row} " + group.map { |column| format("%5.2f", values[:friends][names.index(row), names.index(column)].item) }.join
164
+ end
165
+ end
166
+ end