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/lib/ltn/diag.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Mauro Tortonesi.
|
|
5
|
+
# Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
|
|
6
|
+
|
|
7
|
+
module LTN
|
|
8
|
+
# Sets diagonal quantification on the given variables: they are no longer
|
|
9
|
+
# broadcast against each other but paired element-wise, the i-th individual
|
|
10
|
+
# of each with the i-th of the others. A quantifier over the variables
|
|
11
|
+
# resets them (see LTN.undiag).
|
|
12
|
+
#
|
|
13
|
+
# forall.call(LTN.diag(x, y), formula) # for all i, formula(x_i, y_i)
|
|
14
|
+
#
|
|
15
|
+
# This modifies the variables, replacing their free variables with a shared
|
|
16
|
+
# `diag_...` label.
|
|
17
|
+
#
|
|
18
|
+
# @parameter vars [Array(Variable)] at least two variables with the same number of individuals.
|
|
19
|
+
# @returns [Array(Variable)] the same variables.
|
|
20
|
+
# @raises [TypeError] if an argument is not a Variable.
|
|
21
|
+
# @raises [ArgumentError] if there is only one variable, or their numbers of individuals differ.
|
|
22
|
+
def self.diag(*vars)
|
|
23
|
+
unless vars.all?(Variable)
|
|
24
|
+
raise TypeError, "Expected parameter 'vars' to be Variables, but got #{vars.map(&:class).inspect}"
|
|
25
|
+
end
|
|
26
|
+
if vars.size < 2
|
|
27
|
+
raise ArgumentError, "Expected parameter 'vars' to be more than one Variable, but got just one Variable."
|
|
28
|
+
end
|
|
29
|
+
n_individuals = vars.map { |var| var.shape[0] }
|
|
30
|
+
if n_individuals.uniq.size != 1
|
|
31
|
+
raise ArgumentError, "Expected the given LTN variables to have the same number of individuals, " \
|
|
32
|
+
"but got the following numbers of individuals #{n_individuals.inspect}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
diag_label = "diag_" + vars.map(&:latent_var).join("_")
|
|
36
|
+
vars.each { |var| var.free_vars = [diag_label] }
|
|
37
|
+
vars
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Resets the given variables from diagonal quantification (see LTN.diag)
|
|
41
|
+
# to their own labels.
|
|
42
|
+
#
|
|
43
|
+
# @parameter vars [Array(Variable)] the variables.
|
|
44
|
+
# @returns [Array(Variable)] the same variables.
|
|
45
|
+
# @raises [TypeError] if an argument is not a Variable.
|
|
46
|
+
def self.undiag(*vars)
|
|
47
|
+
unless vars.all?(Variable)
|
|
48
|
+
raise TypeError, "Expected parameter 'vars' to be Variables, but got #{vars.map(&:class).inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
vars.each { |var| var.free_vars = [var.latent_var] }
|
|
52
|
+
vars
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/ltn/function.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Mauro Tortonesi.
|
|
5
|
+
# Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
|
|
6
|
+
|
|
7
|
+
module LTN
|
|
8
|
+
# An LTN function: maps individuals to individuals (tensors of any shape).
|
|
9
|
+
class Function < Torch::NN::Module
|
|
10
|
+
include ModelWrapper
|
|
11
|
+
|
|
12
|
+
# Computes the function, after broadcasting the inputs (see
|
|
13
|
+
# LTN.process_ltn_objects): the result has one leading axis per free
|
|
14
|
+
# variable of the inputs, followed by the dimensions of each output.
|
|
15
|
+
#
|
|
16
|
+
# @parameter inputs [Array(LTNObject)] the arguments.
|
|
17
|
+
# @parameter kwargs [Hash] passed to the model.
|
|
18
|
+
# @returns [LTNObject]
|
|
19
|
+
# @raises [TypeError] if an input is not an LTNObject.
|
|
20
|
+
def forward(*inputs, **kwargs)
|
|
21
|
+
check_inputs(inputs)
|
|
22
|
+
processed, output_vars, output_shape = LTN.process_ltn_objects(inputs)
|
|
23
|
+
output = model.call(*processed.map(&:value), **kwargs)
|
|
24
|
+
|
|
25
|
+
LTNObject.new(output.reshape(output_shape + output.shape.drop(1)).float, output_vars)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Mauro Tortonesi.
|
|
5
|
+
# Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
|
|
6
|
+
|
|
7
|
+
module LTN
|
|
8
|
+
# Fuzzy logic operators and aggregators, the building blocks of
|
|
9
|
+
# LTN::Connective and LTN::Quantifier. See the appendix of the LTN paper
|
|
10
|
+
# (https://arxiv.org/abs/2012.13635) for their definitions.
|
|
11
|
+
#
|
|
12
|
+
# All operators work on tensors of truth values and support torch's usual
|
|
13
|
+
# broadcasting. Each one responds to #call, so it can be invoked as
|
|
14
|
+
# `op.(x, y)`.
|
|
15
|
+
#
|
|
16
|
+
# Differences from LTNtorch's `ltn.fuzzy_ops`:
|
|
17
|
+
# - optional arguments are keywords: `AndProd.new(stable: false)`,
|
|
18
|
+
# `op.(x, y, stable: false)`, `agg.(xs, dim: [0], mask: m, p: 3)`;
|
|
19
|
+
# - Python's ValueError becomes ArgumentError;
|
|
20
|
+
# - SatAgg reports the shapes of non-scalar formulas instead of raising an
|
|
21
|
+
# unrelated error while building that message.
|
|
22
|
+
#
|
|
23
|
+
# Arithmetic between floats and tensors is written tensor-first
|
|
24
|
+
# (`x * c`, `Torch.rsub(x, 1.0)` for `1.0 - x`), and `where` goes through
|
|
25
|
+
# FuzzyOps.where: in torch.rb 0.27.0, `Float op Tensor` converts the float
|
|
26
|
+
# to a float32 tensor, and 0-dim tensors that do not require grad can
|
|
27
|
+
# dispatch to Scalar overloads that return float32 (see TODO_torch-rb.md).
|
|
28
|
+
# Either way, float64 truth values would lose their dtype or precision.
|
|
29
|
+
module FuzzyOps
|
|
30
|
+
# The epsilon of the projections #pi_0 and #pi_1, which keep the stable
|
|
31
|
+
# operators away from the regions where their gradients vanish or explode.
|
|
32
|
+
EPS = 1e-4
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Maps truth values from [0, 1] to (0, 1], so that 0 is never an input.
|
|
37
|
+
#
|
|
38
|
+
# @parameter x [Torch::Tensor] truth values.
|
|
39
|
+
# @returns [Torch::Tensor]
|
|
40
|
+
def pi_0(x)
|
|
41
|
+
x * (1 - EPS) + EPS
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Maps truth values from [0, 1] to [0, 1), so that 1 is never an input.
|
|
45
|
+
#
|
|
46
|
+
# @parameter x [Torch::Tensor] truth values.
|
|
47
|
+
# @returns [Torch::Tensor]
|
|
48
|
+
def pi_1(x)
|
|
49
|
+
x * (1 - EPS)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @parameter values [Array(Torch::Tensor)] truth values.
|
|
53
|
+
# @raises [ArgumentError] if any value is outside [0, 1] (or NaN).
|
|
54
|
+
def check_values(*values)
|
|
55
|
+
values.each do |v|
|
|
56
|
+
next if Torch.all(Torch.logical_and(v.ge(0.0), v.le(1.0))).item
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, "Expected inputs of connectives and quantifiers to be tensors of truth values " \
|
|
59
|
+
"in the range [0., 1.], but got some values outside this range."
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Checks the mask of a guarded quantification.
|
|
64
|
+
#
|
|
65
|
+
# @parameter mask [Torch::Tensor] the mask: true includes a value, false excludes it.
|
|
66
|
+
# @parameter xs [Torch::Tensor] the grounding of the formula being aggregated.
|
|
67
|
+
# @raises [ArgumentError] if the shapes differ or the mask is not boolean.
|
|
68
|
+
def check_mask(mask, xs)
|
|
69
|
+
raise ArgumentError, "'xs' and 'mask' must have the same shape." if mask.shape != xs.shape
|
|
70
|
+
raise ArgumentError, "'mask' must be a boolean tensor." unless mask.dtype == :bool
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Torch.where, safe for 0-dim tensors.
|
|
74
|
+
#
|
|
75
|
+
# In torch.rb 0.27.0, a 0-dim tensor that does not require grad also
|
|
76
|
+
# matches the Scalar parameters of Torch.where's overloads, and the Scalar
|
|
77
|
+
# overloads win, returning the default dtype (float32) whatever the dtype
|
|
78
|
+
# of the tensors. Lifting 0-dim tensors to shape [1] for the call avoids
|
|
79
|
+
# that; the result is the same as PyTorch's.
|
|
80
|
+
#
|
|
81
|
+
# @parameter condition [Torch::Tensor] where to take `input`.
|
|
82
|
+
# @parameter input [Torch::Tensor, Numeric] the values where `condition` holds.
|
|
83
|
+
# @parameter other [Torch::Tensor, Numeric] the values elsewhere.
|
|
84
|
+
# @returns [Torch::Tensor]
|
|
85
|
+
def where(condition, input, other)
|
|
86
|
+
arguments = [condition, input, other]
|
|
87
|
+
tensors = arguments.grep(Torch::Tensor)
|
|
88
|
+
return Torch.where(condition, input, other) unless tensors.any? { |t| t.dim == 0 }
|
|
89
|
+
|
|
90
|
+
lifted = arguments.map { |a| (a.is_a?(Torch::Tensor) && a.dim == 0) ? a.reshape([1]) : a }
|
|
91
|
+
result = Torch.where(*lifted)
|
|
92
|
+
(tensors.all? { |t| t.dim == 0 }) ? result.reshape([]) : result
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @parameter dim [Integer, Array(Integer), nil] the dimension(s) to reduce;
|
|
96
|
+
# nil reduces all of them.
|
|
97
|
+
# @returns [Array(Integer)] the dimensions in the form torch.rb expects,
|
|
98
|
+
# where the empty array reduces all of them.
|
|
99
|
+
def reduction_dims(dim)
|
|
100
|
+
dim.nil? ? [] : Array(dim)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Shared by the operators with a stable variant: the `stable` flag can be
|
|
104
|
+
# set in the constructor and overridden on each call.
|
|
105
|
+
module Stable
|
|
106
|
+
# @attribute [Boolean] whether to use the stable variant by default.
|
|
107
|
+
attr_accessor :stable
|
|
108
|
+
|
|
109
|
+
# @parameter stable [Boolean] whether to use the stable variant by default.
|
|
110
|
+
def initialize(stable: true)
|
|
111
|
+
@stable = stable
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def inspect
|
|
115
|
+
"#{self.class.name.split("::").last}(stable: #{stable})"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def stable?(override)
|
|
121
|
+
override.nil? ? stable : override
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Abstract base class of the connective operators.
|
|
126
|
+
class ConnectiveOperator
|
|
127
|
+
# @raises [NotImplementedError] always: subclasses implement #call.
|
|
128
|
+
def call(*args, **kwargs)
|
|
129
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def inspect
|
|
133
|
+
"#{self.class.name.split("::").last}()"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def to_s
|
|
137
|
+
inspect
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Abstract base class of the unary connective operators.
|
|
142
|
+
class UnaryConnectiveOperator < ConnectiveOperator
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Abstract base class of the binary connective operators.
|
|
146
|
+
class BinaryConnectiveOperator < ConnectiveOperator
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Standard negation: 1 - x.
|
|
150
|
+
class NotStandard < UnaryConnectiveOperator
|
|
151
|
+
def call(x)
|
|
152
|
+
Torch.rsub(x, 1.0)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Gödel negation: 1 if x == 0, 0 otherwise.
|
|
157
|
+
class NotGodel < UnaryConnectiveOperator
|
|
158
|
+
def call(x)
|
|
159
|
+
Torch.eq(x, 0.0).float
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Gödel conjunction: min(x, y).
|
|
164
|
+
class AndMin < BinaryConnectiveOperator
|
|
165
|
+
def call(x, y)
|
|
166
|
+
Torch.minimum(x, y)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Goguen (product) conjunction: x * y.
|
|
171
|
+
class AndProd < BinaryConnectiveOperator
|
|
172
|
+
include Stable
|
|
173
|
+
|
|
174
|
+
def call(x, y, stable: nil)
|
|
175
|
+
x, y = FuzzyOps.pi_0(x), FuzzyOps.pi_0(y) if stable?(stable)
|
|
176
|
+
Torch.mul(x, y)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Łukasiewicz conjunction: max(x + y - 1, 0).
|
|
181
|
+
class AndLuk < BinaryConnectiveOperator
|
|
182
|
+
def call(x, y)
|
|
183
|
+
Torch.maximum(x + y - 1.0, Torch.zeros_like(x))
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Gödel disjunction: max(x, y).
|
|
188
|
+
class OrMax < BinaryConnectiveOperator
|
|
189
|
+
def call(x, y)
|
|
190
|
+
Torch.maximum(x, y)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Goguen disjunction (probabilistic sum): x + y - x * y.
|
|
195
|
+
class OrProbSum < BinaryConnectiveOperator
|
|
196
|
+
include Stable
|
|
197
|
+
|
|
198
|
+
def call(x, y, stable: nil)
|
|
199
|
+
x, y = FuzzyOps.pi_1(x), FuzzyOps.pi_1(y) if stable?(stable)
|
|
200
|
+
x + y - Torch.mul(x, y)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Łukasiewicz disjunction: min(x + y, 1).
|
|
205
|
+
class OrLuk < BinaryConnectiveOperator
|
|
206
|
+
def call(x, y)
|
|
207
|
+
Torch.minimum(x + y, Torch.ones_like(x))
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Kleene-Dienes implication: max(1 - x, y).
|
|
212
|
+
class ImpliesKleeneDienes < BinaryConnectiveOperator
|
|
213
|
+
def call(x, y)
|
|
214
|
+
Torch.maximum(Torch.rsub(x, 1.0), y)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Gödel implication: 1 if x <= y, y otherwise.
|
|
219
|
+
class ImpliesGodel < BinaryConnectiveOperator
|
|
220
|
+
def call(x, y)
|
|
221
|
+
FuzzyOps.where(Torch.le(x, y), Torch.ones_like(x), y)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Reichenbach implication: 1 - x + x * y.
|
|
226
|
+
class ImpliesReichenbach < BinaryConnectiveOperator
|
|
227
|
+
include Stable
|
|
228
|
+
|
|
229
|
+
def call(x, y, stable: nil)
|
|
230
|
+
x, y = FuzzyOps.pi_0(x), FuzzyOps.pi_1(y) if stable?(stable)
|
|
231
|
+
Torch.rsub(x, 1.0) + Torch.mul(x, y)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Goguen implication: 1 if x <= y, y / x otherwise.
|
|
236
|
+
class ImpliesGoguen < BinaryConnectiveOperator
|
|
237
|
+
include Stable
|
|
238
|
+
|
|
239
|
+
def call(x, y, stable: nil)
|
|
240
|
+
x = FuzzyOps.pi_0(x) if stable?(stable)
|
|
241
|
+
FuzzyOps.where(Torch.le(x, y), Torch.ones_like(x), Torch.div(y, x))
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Łukasiewicz implication: min(1 - x + y, 1).
|
|
246
|
+
class ImpliesLuk < BinaryConnectiveOperator
|
|
247
|
+
def call(x, y)
|
|
248
|
+
Torch.minimum(Torch.rsub(x, 1.0) + y, Torch.ones_like(x))
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Equivalence, as the conjunction of the implications in both directions.
|
|
253
|
+
class Equiv < BinaryConnectiveOperator
|
|
254
|
+
attr_reader :and_op, :implies_op
|
|
255
|
+
|
|
256
|
+
# @parameter and_op [BinaryConnectiveOperator] the conjunction.
|
|
257
|
+
# @parameter implies_op [BinaryConnectiveOperator] the implication.
|
|
258
|
+
def initialize(and_op, implies_op)
|
|
259
|
+
@and_op = and_op
|
|
260
|
+
@implies_op = implies_op
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def call(x, y)
|
|
264
|
+
and_op.call(implies_op.call(x, y), implies_op.call(y, x))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def inspect
|
|
268
|
+
"Equiv(and_op: #{and_op.inspect}, implies_op: #{implies_op.inspect})"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Abstract base class of the aggregation operators.
|
|
273
|
+
#
|
|
274
|
+
# Every aggregator responds to `call(xs, dim: nil, keepdim: false, mask: nil)`:
|
|
275
|
+
# `dim` is the dimension (or dimensions) to aggregate, nil for all of them,
|
|
276
|
+
# and `mask` is a boolean tensor shaped like `xs` that excludes the values
|
|
277
|
+
# where it is false (used for guarded quantification).
|
|
278
|
+
class AggregationOperator
|
|
279
|
+
# @raises [NotImplementedError] always: subclasses implement #call.
|
|
280
|
+
def call(*args, **kwargs)
|
|
281
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def inspect
|
|
285
|
+
"#{self.class.name.split("::").last}()"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def to_s
|
|
289
|
+
inspect
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
private
|
|
293
|
+
|
|
294
|
+
# Mean of `xs` over `dims`, counting only the values where `mask` is true.
|
|
295
|
+
def masked_mean(xs, mask, dims, keepdim)
|
|
296
|
+
FuzzyOps.check_mask(mask, xs)
|
|
297
|
+
numerator = Torch.sum(FuzzyOps.where(mask.logical_not, Torch.zeros_like(xs), xs), dim: dims, keepdim: keepdim)
|
|
298
|
+
denominator = Torch.sum(mask, dim: dims, keepdim: keepdim)
|
|
299
|
+
Torch.div(numerator, denominator)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Shared by the pMean aggregators: `p` and `stable` can be set in the
|
|
304
|
+
# constructor and overridden on each call.
|
|
305
|
+
module PMeanParameters
|
|
306
|
+
# @attribute [Numeric] the exponent of the generalized mean.
|
|
307
|
+
attr_accessor :p
|
|
308
|
+
|
|
309
|
+
# @attribute [Boolean] whether to use the stable variant by default.
|
|
310
|
+
attr_accessor :stable
|
|
311
|
+
|
|
312
|
+
# @parameter p [Numeric] the exponent of the generalized mean.
|
|
313
|
+
# @parameter stable [Boolean] whether to use the stable variant by default.
|
|
314
|
+
def initialize(p: 2, stable: true)
|
|
315
|
+
@p = p
|
|
316
|
+
@stable = stable
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def inspect
|
|
320
|
+
"#{self.class.name.split("::").last}(p: #{p}, stable: #{stable})"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Minimum, an approximation of the universal quantifier.
|
|
325
|
+
class AggregMin < AggregationOperator
|
|
326
|
+
def call(xs, dim: nil, keepdim: false, mask: nil)
|
|
327
|
+
unless mask.nil?
|
|
328
|
+
FuzzyOps.check_mask(mask, xs)
|
|
329
|
+
# 1 is the largest truth value, so masked-out values never win the
|
|
330
|
+
# minimum. As in LTNtorch, this turns the result into float64.
|
|
331
|
+
xs = FuzzyOps.where(mask.logical_not, 1.0, xs.double)
|
|
332
|
+
end
|
|
333
|
+
Torch.amin(xs, dim: FuzzyOps.reduction_dims(dim), keepdim: keepdim)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Arithmetic mean.
|
|
338
|
+
class AggregMean < AggregationOperator
|
|
339
|
+
def call(xs, dim: nil, keepdim: false, mask: nil)
|
|
340
|
+
dims = FuzzyOps.reduction_dims(dim)
|
|
341
|
+
return masked_mean(xs, mask, dims, keepdim) unless mask.nil?
|
|
342
|
+
|
|
343
|
+
Torch.mean(xs, dim: dims, keepdim: keepdim)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Generalized mean (pMean), an approximation of the existential quantifier:
|
|
348
|
+
# (mean(x ** p)) ** (1 / p).
|
|
349
|
+
class AggregPMean < AggregationOperator
|
|
350
|
+
include PMeanParameters
|
|
351
|
+
|
|
352
|
+
def call(xs, dim: nil, keepdim: false, mask: nil, p: nil, stable: nil)
|
|
353
|
+
p = self.p if p.nil?
|
|
354
|
+
stable = self.stable if stable.nil?
|
|
355
|
+
dims = FuzzyOps.reduction_dims(dim)
|
|
356
|
+
|
|
357
|
+
xs = FuzzyOps.pi_0(xs) if stable
|
|
358
|
+
xs = Torch.pow(xs, p)
|
|
359
|
+
mean = mask.nil? ? Torch.mean(xs, dim: dims, keepdim: keepdim) : masked_mean(xs, mask, dims, keepdim)
|
|
360
|
+
Torch.pow(mean, 1.0 / p)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Generalized mean of the errors (pMeanError), an approximation of the
|
|
365
|
+
# universal quantifier: 1 - (mean((1 - x) ** p)) ** (1 / p).
|
|
366
|
+
class AggregPMeanError < AggregationOperator
|
|
367
|
+
include PMeanParameters
|
|
368
|
+
|
|
369
|
+
def call(xs, dim: nil, keepdim: false, mask: nil, p: nil, stable: nil)
|
|
370
|
+
p = self.p if p.nil?
|
|
371
|
+
stable = self.stable if stable.nil?
|
|
372
|
+
dims = FuzzyOps.reduction_dims(dim)
|
|
373
|
+
|
|
374
|
+
xs = FuzzyOps.pi_1(xs) if stable
|
|
375
|
+
xs = Torch.pow(Torch.rsub(xs, 1.0), p)
|
|
376
|
+
mean = mask.nil? ? Torch.mean(xs, dim: dims, keepdim: keepdim) : masked_mean(xs, mask, dims, keepdim)
|
|
377
|
+
Torch.rsub(Torch.pow(mean, 1.0 / p), 1.0)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Aggregates the truth values of the closed formulas of a knowledge base
|
|
382
|
+
# into its overall satisfaction level, typically to build a loss as
|
|
383
|
+
# `1 - sat_agg.(...)`.
|
|
384
|
+
class SatAgg
|
|
385
|
+
attr_reader :agg_op
|
|
386
|
+
|
|
387
|
+
# @parameter agg_op [AggregationOperator] how to aggregate the truth values.
|
|
388
|
+
# @raises [TypeError] if `agg_op` is not an aggregation operator.
|
|
389
|
+
def initialize(agg_op = AggregPMeanError.new(p: 2))
|
|
390
|
+
unless agg_op.is_a?(AggregationOperator)
|
|
391
|
+
raise TypeError, "SatAgg.new: argument 'agg_op' (position 1) must be an AggregationOperator, not #{agg_op.class}"
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
@agg_op = agg_op
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# @parameter closed_formulas [Array(LTNObject | Torch::Tensor)] scalar truth values.
|
|
398
|
+
# @returns [Torch::Tensor] the aggregated (scalar) truth value.
|
|
399
|
+
# @raises [TypeError] if a formula is neither an LTNObject nor a tensor.
|
|
400
|
+
# @raises [ArgumentError] if a formula is not closed (its value is not a
|
|
401
|
+
# scalar) or not in [0, 1].
|
|
402
|
+
def call(*closed_formulas)
|
|
403
|
+
unless closed_formulas.all? { |f| f.is_a?(LTNObject) || f.is_a?(Torch::Tensor) }
|
|
404
|
+
raise TypeError, "Expected parameter 'closed_formulas' to be LTNObjects and/or tensors, " \
|
|
405
|
+
"but got #{closed_formulas.map(&:class).inspect}"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
truth_values = closed_formulas.map { |f| f.is_a?(LTNObject) ? f.value : f }
|
|
409
|
+
unless truth_values.all? { |v| v.shape == [] }
|
|
410
|
+
raise ArgumentError, "Expected parameter 'closed_formulas' to be LTNObjects and/or tensors containing " \
|
|
411
|
+
"scalars, but got the following shapes: #{truth_values.map(&:shape).inspect}"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
truth_values = Torch.stack(truth_values, dim: 0)
|
|
415
|
+
FuzzyOps.check_values(truth_values)
|
|
416
|
+
agg_op.call(truth_values, dim: 0)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def inspect
|
|
420
|
+
"SatAgg(agg_op: #{agg_op.inspect})"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def to_s
|
|
424
|
+
inspect
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Mauro Tortonesi.
|
|
5
|
+
# Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
|
|
6
|
+
|
|
7
|
+
module LTN
|
|
8
|
+
# Wraps a function (a lambda, proc, method or block) in a Torch::NN::Module,
|
|
9
|
+
# to ground a non-trainable predicate or function.
|
|
10
|
+
class LambdaModel < Torch::NN::Module
|
|
11
|
+
# @attribute [#call] the wrapped function.
|
|
12
|
+
attr_reader :func
|
|
13
|
+
|
|
14
|
+
# @parameter func [#call] the function to wrap.
|
|
15
|
+
def initialize(func)
|
|
16
|
+
super()
|
|
17
|
+
@func = func
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def forward(*inputs, **kwargs)
|
|
21
|
+
func.call(*inputs, **kwargs)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Mauro Tortonesi.
|
|
5
|
+
# Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
|
|
6
|
+
|
|
7
|
+
module LTN
|
|
8
|
+
# The output of every LTN operation: a grounding (a tensor) together with
|
|
9
|
+
# the labels of the free variables appearing in it, one per leading axis of
|
|
10
|
+
# the tensor, in order.
|
|
11
|
+
#
|
|
12
|
+
# Named `LTNObject`, as in LTNtorch, rather than `LTN::Object`, which would
|
|
13
|
+
# shadow Ruby's `Object` for every unqualified reference inside `LTN`.
|
|
14
|
+
class LTNObject
|
|
15
|
+
# @attribute [Torch::Tensor] the grounding.
|
|
16
|
+
attr_accessor :value
|
|
17
|
+
|
|
18
|
+
# @attribute [Array(String)] labels of the free variables, one per leading axis of #value.
|
|
19
|
+
attr_accessor :free_vars
|
|
20
|
+
|
|
21
|
+
# @parameter value [Torch::Tensor] the grounding.
|
|
22
|
+
# @parameter var_labels [Array(String)] labels of the free variables in `value`.
|
|
23
|
+
# @raises [TypeError] if `value` is not a tensor or `var_labels` is not an array of strings.
|
|
24
|
+
def initialize(value, var_labels)
|
|
25
|
+
unless value.is_a?(Torch::Tensor)
|
|
26
|
+
raise TypeError, "LTNObject.new: argument 'value' (position 1) must be a Torch::Tensor, not #{value.class}"
|
|
27
|
+
end
|
|
28
|
+
unless var_labels.is_a?(Array) && var_labels.all?(String)
|
|
29
|
+
raise TypeError,
|
|
30
|
+
"LTNObject.new: argument 'var_labels' (position 2) must be an Array of String, not #{var_labels.class}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@value = value
|
|
34
|
+
@free_vars = var_labels
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @returns [Array(Integer)] the shape of the grounding.
|
|
38
|
+
def shape
|
|
39
|
+
value.shape
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inspect
|
|
43
|
+
"LTNObject(value=#{value.inspect}, free_vars=#{free_vars.inspect})"
|
|
44
|
+
end
|
|
45
|
+
alias_method :to_s, :inspect
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Mauro Tortonesi.
|
|
5
|
+
# Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
|
|
6
|
+
|
|
7
|
+
module LTN
|
|
8
|
+
# The construction shared by Predicate and Function: they are grounded
|
|
9
|
+
# either by a Torch::NN::Module (trainable) or by a function (not trainable).
|
|
10
|
+
module ModelWrapper
|
|
11
|
+
# @attribute [Torch::NN::Module] the grounding; a LambdaModel for a function.
|
|
12
|
+
attr_reader :model
|
|
13
|
+
|
|
14
|
+
# Ground the symbol with either `model` or a function, given as `func:`
|
|
15
|
+
# or as a block:
|
|
16
|
+
#
|
|
17
|
+
# LTN::Predicate.new(model)
|
|
18
|
+
# LTN::Predicate.new(func: ->(x) { Torch.sigmoid(x.sum(1)) })
|
|
19
|
+
# LTN::Predicate.new { |x| Torch.sigmoid(x.sum(1)) }
|
|
20
|
+
#
|
|
21
|
+
# @parameter model [Torch::NN::Module, nil] a model taking one tensor per argument.
|
|
22
|
+
# @parameter func [#call, nil] a function taking one tensor per argument.
|
|
23
|
+
# @raises [ArgumentError] unless exactly one of model, func and block is given.
|
|
24
|
+
# @raises [TypeError] if `model` is not a Torch::NN::Module or `func` is not callable.
|
|
25
|
+
def initialize(model = nil, func: nil, &block)
|
|
26
|
+
super()
|
|
27
|
+
groundings = [model, func, block].compact
|
|
28
|
+
if groundings.size > 1
|
|
29
|
+
raise ArgumentError, "More than one of model, func and block have been specified. " \
|
|
30
|
+
"Expected only one of them to be specified."
|
|
31
|
+
end
|
|
32
|
+
if groundings.empty?
|
|
33
|
+
raise ArgumentError, "None of model, func and block have been specified. " \
|
|
34
|
+
"Expected one of them to be specified."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@model = if model
|
|
38
|
+
unless model.is_a?(Torch::NN::Module)
|
|
39
|
+
raise TypeError, "#{symbol_name}.new: argument 'model' (position 1) must be a Torch::NN::Module, " \
|
|
40
|
+
"not #{model.class}"
|
|
41
|
+
end
|
|
42
|
+
model
|
|
43
|
+
else
|
|
44
|
+
func ||= block
|
|
45
|
+
if !func.respond_to?(:call) || func.is_a?(Torch::NN::Module)
|
|
46
|
+
raise TypeError, "#{symbol_name}.new: argument 'func' must be a function, not #{func.class}"
|
|
47
|
+
end
|
|
48
|
+
LambdaModel.new(func)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def inspect
|
|
53
|
+
"#{symbol_name}(model=#{model.inspect})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_s
|
|
57
|
+
inspect
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def symbol_name
|
|
63
|
+
self.class.name.split("::").last
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def check_inputs(inputs)
|
|
67
|
+
return if inputs.all?(LTNObject)
|
|
68
|
+
|
|
69
|
+
raise TypeError, "Expected parameter 'inputs' to be LTNObjects, but got #{inputs.map(&:class).inspect}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Mauro Tortonesi.
|
|
5
|
+
# Ported from LTNtorch, copyright 2021-2024 by Tommaso Carraro.
|
|
6
|
+
|
|
7
|
+
module LTN
|
|
8
|
+
# An LTN predicate: maps individuals to truth values in [0, 1].
|
|
9
|
+
#
|
|
10
|
+
# is_positive = LTN::Predicate.new { |x| Torch.sigmoid(x.sum(1)) }
|
|
11
|
+
# x = LTN::Variable.new("x", Torch.randn(4, 3))
|
|
12
|
+
# is_positive.call(x) # => an LTNObject with 4 truth values and free_vars ["x"]
|
|
13
|
+
class Predicate < Torch::NN::Module
|
|
14
|
+
include ModelWrapper
|
|
15
|
+
|
|
16
|
+
# Computes the truth values of the predicate, after broadcasting the
|
|
17
|
+
# inputs (see LTN.process_ltn_objects): the result has one axis per free
|
|
18
|
+
# variable of the inputs.
|
|
19
|
+
#
|
|
20
|
+
# @parameter inputs [Array(LTNObject)] the arguments.
|
|
21
|
+
# @parameter kwargs [Hash] passed to the model.
|
|
22
|
+
# @returns [LTNObject]
|
|
23
|
+
# @raises [TypeError] if an input is not an LTNObject.
|
|
24
|
+
# @raises [ArgumentError] if the model returns values outside [0, 1].
|
|
25
|
+
def forward(*inputs, **kwargs)
|
|
26
|
+
check_inputs(inputs)
|
|
27
|
+
processed, output_vars, output_shape = LTN.process_ltn_objects(inputs)
|
|
28
|
+
output = model.call(*processed.map(&:value), **kwargs)
|
|
29
|
+
|
|
30
|
+
unless Torch.all(Torch.logical_and(output.ge(0.0), output.le(1.0))).item
|
|
31
|
+
raise ArgumentError, "Expected the output of a predicate to be in the range [0., 1.], but got some " \
|
|
32
|
+
"values outside of this range. Check your predicate implementation!"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
LTNObject.new(output.reshape(output_shape).float, output_vars)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|