Transformations¶
Transformations allow you to customize the way numerical quantities enter the recommendation process. They can be used to express various optimization objectives and imprint your domain knowledge or use case requirements on the quantities being optimized. See, for example, the targets user guide to learn how to do this.
Current Scope
Currently, transformations are only used for the
NumericalTarget
class but it is planned to enable
their use for parameters as well.
Basic Transformations¶
The following pre-defined basic transformation types are available via the
baybe.transformations
module:
IdentityTransformation
: \(f(x) = x\)AbsoluteTransformation
: \(f(x) = |x|\)ExponentialTransformation
: \(f(x) = e^x\)LogarithmicTransformation
: \(f(x) = \ln(x)\)PowerTransformation
: \(f(x) = x^p\)
Advanced Transformations¶
In addition to the basic transformations, the
baybe.transformations
module provides a number of advanced transformation types
that allow for more customization:
ClampingTransformation¶
The ClampingTransformation
is used to limit the
range of the input values to a specified interval.
Transformation rule
where \(c_\text{min}\) and \(c_\text{max}\) are the bounds specified for the transformation.
Example
from baybe.transformations import ClampingTransformation
t_min = ClampingTransformation(min=10) # clamps to [10, +inf)
t_max = ClampingTransformation(max=20) # clamps to (-inf, 20]
t_both = ClampingTransformation(min=10, max=20) # clamps to [10, 20]
AffineTransformation¶
The AffineTransformation
applies an affine
transformation to the given input, i.e., it scales and shifts the incoming values.
Transformation rule
where \(a\) is the scaling factor and \(b\) is the shift value of the transformation.
Example
from baybe.transformations import AffineTransformation
t = AffineTransformation(factor=2) # scales by 2
t = AffineTransformation(shift=3) # shifts by 3
t = AffineTransformation(factor=2, shift=3) # scales, *then* shifts
t = AffineTransformation(factor=2, shift=3, shift_first=True) # shifts, *then* scales
TwoSidedAffineTransformation¶
The TwoSidedAffineTransformation
is a piecewise
transformation with two affine segments that meet at a midpoint.
Transformation rule
where \(c_\text{left}\) and \(c_\text{right}\) are the slopes of the left and right affine segments, respectively, and \(c_\text{mid}\) specifies the midpoint where the two segments meet.
Example
from baybe.transformations import TwoSidedAffineTransformation
t = TwoSidedAffineTransformation(slope_left=-1, slope_right=1) # absolute value
t = TwoSidedAffineTransformation(slope_left=-1, slope_right=0, midpoint=1) # hinge loss
SigmoidTransformation¶
The SigmoidTransformation
normalizes its input
to the range \([0, 1]\) using a sigmoid function.
Transformation rule
where \(c\) is the center point where the curve crosses the value 0.5 and \(a\) is a parameter controlling the steepness. Note that the transformation can also be specified using anchors points instead, as demonstrated below.
Example
from baybe.transformations import SigmoidTransformation
t = SigmoidTransformation(center=0.0, steepness=1.0) # vanilla sigmoid function
t = SigmoidTransformation(center=1.0, steepness=2.0) # shifted and steeper
t = SigmoidTransformation.from_anchors(
anchors=[(1, 0.1), (2, 0.9)] # passes through anchors
)
BellTransformation¶
The BellTransformation
passes its input through a
bell-shaped function (i.e. an unnormalized Gaussian). This is useful for steering
target values to specific set points.
Transformation rule
where \(c\) is the center of the bell curve and \(\sigma\) is a parameter controlling its width. The latter has the same interpretation as the standard deviation of a Gaussian distribution except that it does not affect the magnitude of the curve.
Example
from baybe.transformations import BellTransformation
t = BellTransformation(center=0, sigma=1) # like an unnormalized standard normal
t = BellTransformation(center=5, sigma=2) # twice as wide and shifted to the right by 5
TriangularTransformation¶
The TriangularTransformation
is a piecewise affine
transformation with the shape of a triangle. This is useful for steering target values
to specific set points with symmetric or asymmetric penalty.
Transformation rule
where \(c_\text{min}\) and \(c_\text{max}\) are the cutoff values of the triangle, respectively, and \(c_\text{peak}\) is its peak location. Note that there also exist convenience constructors that allow for alternative parameterizations of the transformation, as exemplified below.
Example
from baybe.transformations import TriangularTransformation
# Symmetric triangle with peak at 3, reaching zero at 1 and 5
t_sym1 = TriangularTransformation(cutoffs=(1, 5))
t_sym2 = TriangularTransformation.from_width(peak=3, width=4)
t_sym3 = TriangularTransformation.from_margins(peak=3, margins=(2, 2))
assert t_sym1 == t_sym2 == t_sym3
# Positively skewed triangle with peak at 2 (same cutoffs as above)
t_skew1 = TriangularTransformation(cutoffs=(1, 5), peak=2)
t_skew2 = TriangularTransformation.from_margins(peak=2, margins=(1, 3))
assert t_skew1 == t_skew2
Composite Transformations¶
Instead of applying individual basic or
advanced transformations directly, you can also use them as
building blocks to enable more complex types of operations. This is enabled through the
ChainedTransformation
,
AdditiveTransformation
and
MultiplicativeTransformation
classes, which
allow you to combine multiple transformations into a single one.
Chaining¶
The ChainedTransformation
class represents the
mathematical concept of function
composition, which allows you to
“chain” multiple transformations together. It gives you a higher-level transformation
object that applies the specified transformation steps in sequence, where the output of
one transformation serves as the input to the next.
Order of Operations
Since the given transformations are applied sequentially, the order in which they are
specified matters! More specifically, the first transformation passed to
ChainedTransformation
gets applied first, then
the second, and so on. Therefore, ChainedTransformation([f, g, h])
represent the
mathematical operation \((h \circ g \circ f)(x) = h(g(f(x)))\), where the order in the
notation is reversed.
Convenience Construction
Instead of explicitly calling the
ChainedTransformation
constructor to chain transformations, you can alternatively:
from baybe.transformations import (
AffineTransformation,
ChainedTransformation,
PowerTransformation,
TwoSidedAffineTransformation,
)
twosided = TwoSidedAffineTransformation(slope_left=0, slope_right=1)
power = PowerTransformation(exponent=2)
shift = AffineTransformation(shift=1)
# Create a transformation representing a shifted one-sided quadratic function:
# 1) First, we cut the left side by multiplying by zero
# 2) Then, we apply the quadratic transformation
# 3) Finally, we shift to the right
chain1 = ChainedTransformation([twosided, power, shift]) # explicit construction
chain2 = twosided | power | shift # using overloaded pipe operator
chain3 = twosided.chain(power).chain(shift) # via method chaining
assert chain1 == chain2 == chain3
Compression
BayBE is smart when it comes to chaining transformations in that it automatically compresses the resulting chain to remove redundancies, by
dropping unnecessary identity transformations,
combining successive affine transformations,
and ignoring the chaining layer for comparison operations when it’s not needed.
from baybe.transformations import (
AffineTransformation,
ChainedTransformation,
IdentityTransformation,
)
t1 = ChainedTransformation(
[
IdentityTransformation(),
AffineTransformation(factor=2),
AffineTransformation(shift=3),
]
)
t2 = (
IdentityTransformation()
| AffineTransformation(factor=2)
| AffineTransformation(shift=3)
)
t3 = AffineTransformation(factor=2, shift=3) # compressed version
assert isinstance(t1, ChainedTransformation) # t1 is a explicitly constructed as chain
assert not isinstance(t2, ChainedTransformation) # not a chain due to auto-compression
assert t1 == t2 == t3 # all are equal, even though t1 is a ChainedTransformation object
Creation from Existing Transformations
For common chaining operations, BayBE provides a set convenience methods that
allow you to quickly create new transformations from existing ones
(see Transformation
for all options).
t = IdentityTransformation() # start with **any** existing transformation
t1 = t.abs() # compute absolute value
t2 = t.negate() # negate the input
t3 = t2.clamp(min=-1) # clamp to [-1, +inf)
t4 = t3 + 5 # add a constant value
t5 = t4 * 10 # multiply by a constant factor
Addition¶
The AdditiveTransformation
computes the sum
of the outputs of two transformations applied to the same input. More precisely,
AdditiveTransformation([f, g])
computes the transformation \(h(x) := f(x) + g(x)\).
Convenience Construction
Instead of explicitly calling the
AdditiveTransformation
constructor,
you can also use the overloaded +
operator to add transformations.
import torch
from baybe.transformations import AdditiveTransformation, PowerTransformation
p1 = PowerTransformation(exponent=2)
p2 = PowerTransformation(exponent=3)
values = torch.linspace(0, 1, steps=100)
t1 = AdditiveTransformation([p1, p2]) # explicit construction
t2 = p1 + p2 # using overloaded addition operator
assert t1 == t2
assert torch.equal(t1(values), p1(values) + p2(values))
Multiplication¶
Analogous to the additive case above, the
MultiplicativeTransformation
computes the
product of the outputs of two transformations applied to the same input. More precisely,
MultiplicativeTransformation([f, g])
computes the transformation
\(h(x) := f(x) \cdot g(x)\).
Convenience Construction
Instead of explicitly calling the
MultiplicativeTransformation
constructor,
you can also use the overloaded *
operator to multiply transformations.
import torch
from baybe.transformations import MultiplicativeTransformation, PowerTransformation
p1 = PowerTransformation(exponent=2)
p2 = PowerTransformation(exponent=3)
values = torch.linspace(0, 1, steps=100)
t1 = MultiplicativeTransformation([p1, p2]) # explicit construction
t2 = p1 * p2 # using overloaded multiplication operator
assert t1 == t2
assert torch.equal(t1(values), p1(values) * p2(values))
Custom Transformations¶
If none of the pre-defined basic or
advanced transformations fit your needs and composing
them does also not bring you to the desired result, you can
easily define your custom logic using the
CustomTransformation
class, which accepts any
one-argument torch
callable as transformation function:
import torch
from baybe.transformations import CustomTransformation
t = CustomTransformation(torch.sin)
Automatic Wrapping
When embedding custom transformations into another context, wrapping the torch
callable into a CustomTransformation
happens
automatically, so you can use them just like any other built-in transformation:
import torch
from baybe.targets import NumericalTarget
from baybe.transformations import CustomTransformation
t = NumericalTarget(name="Sinusoid Target", transformation=torch.sin)
Chaining Custom Transformations
A convenient feature is that you can chain custom transformations with built-in ones directly, without needing to explicitly wrap them. Here is an example, demonstrating the same chaining operation from the most concise to the most explicit construction:
import torch
from baybe.transformations import (
AbsoluteTransformation,
ChainedTransformation,
CustomTransformation,
)
t1 = torch.sin(AbsoluteTransformation())
t2 = AbsoluteTransformation() | torch.sin
t3 = AbsoluteTransformation() | CustomTransformation(torch.sin)
t4 = AbsoluteTransformation().chain(CustomTransformation(torch.sin))
t5 = ChainedTransformation([AbsoluteTransformation(), CustomTransformation(torch.sin)])
assert t1 == t2 == t3 == t4 == t5
Serialization
Due to the arbitrary logic that can be involved, (de-)serialization of custom transformations not supported.
Equality¶
An important aspect to notice is that there are often many ways to represent one and the same mathematical transformation, by following different construction paths.
While these construction paths lead to the identical input-output behavior, the
corresponding Transformation
objects representing
these mappings are not necessarily equal because they rely on different building blocks
internally. While BayBE automatically unifies these
representations for trivial cases behind the scenes, it is not always possible to do so.
To demonstrate this effect, let us recreate the input-output mapping from the chaining section using a slightly different construction path:
import torch
# Logically, we can achieve the same result by interchanging the first two steps:
# 1) Here, we apply the quadratic transformation first
# 2) Then, we cut as a second step
# 3) Finally, we shift
chain4 = power | twosided | shift # different order of operations
# While these two constructions are mathematically equivalent, they are not "equal" from
# an object perspective, because they rely on different transformation steps:
values = torch.linspace(0, 2, steps=100)
assert torch.equal(chain1(values), chain4(values)) # they produce the same output
assert chain1 != chain4 # but they are not "equal" objects