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
|
@@ -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
|