Containers#
The ivy.Container inherits from dict, and is useful for storing nested data. For example, the container is equally suitable for storing batches of training data, or for storing the weights of a network.
The methods of the ivy.Container
class are more varied than those of the ivy.Array
.
All methods of the ivy.Array
are instance methods, and almost all of them directly wrap a function in the functional API.
For the ivy.Container
, there are also methods which are specific to the container itself, for performing nested operations on the leaves of the container for example.
Overall, this results in the following three mutually exclusive groups of ivy.Container
methods.
Each of these are explained in the following sub-sections.
Container instance methods
API instance methods
API special methods
Container Instance Methods#
Container instance methods are methods which are specific to the container itself. A few examples include ivy.Container.cont_map which is used for mapping a function to all leaves of the container, ivy.Container.cont_all_true which determines if all container leaves evaluate to boolean True, and ivy.Container.cont_to_iterator which returns an iterator for traversing the leaves of the container.
There are many more examples, check out the abstract ContainerBase class to see some more!
API Instance Methods#
The API instance methods serve a similar purpose to the instance methods of the ivy.Array
class.
They enable functions in Ivy’s functional API to be called as instance methods on the ivy.Container
class.
The difference is that with the ivy.Container
, the API function is applied recursively to all the leaves of the container.
The ivy.Container
instance methods should exactly match the instance methods of the ivy.Array
, both in terms of the methods implemented and the argument which self
replaces in the function being called.
This means self
should always replace the first array argument in the function.
ivy.Container.add is a good example.
However, as with the ivy.Array
class, it’s important to bear in mind that this is not necessarily the first argument, although in most cases it will be.
We also do not set the out
argument to self
for instance methods.
If the only array argument is the out
argument, then we do not implement this instance method.
For example, we do not implement an instance method for ivy.zeros.
As is the case for ivy.Array
, the organization of these instance methods follows the same organizational structure as the files in the functional API.
The ivy.Container
class inherits from many category-specific array classes, such as ContainerWithElementwise, each of which implements the category-specific instance methods.
As with ivy.Array
, given the simple set of rules which underpin how these instance methods should all be implemented, if a source-code implementation is not found, then this instance method is added programmatically. This serves as a helpful backup in cases where some instance methods are accidentally missed out.
Again, the benefit of the source code implementations is that this makes the code much more readable, with important methods not being entirely absent from the code. It also enables other helpful perks, such as auto-completions in the IDE etc.
API Special Methods#
All non-operator special methods are implemented in ContainerBase, which is the abstract base class for all containers.
These special methods include __repr__ which controls how the container is printed in the terminal, __getattr__ that primarily enables keys in the underlying dict
to be queried as attributes, whereas if no attribute, item or method is found which matches the name provided on the container itself, then the leaves will also be recursively traversed, searching for the attribute.
If it turns out to be a callable function on the leaves, then it will call the function on each leaf and update the leaves with the returned results, for a more detailed explanation with examples, see the code block below.
__setattr__ that enables attribute setting to update the underlying dict
, __getitem__ that enables the underlying dict
to be queried via a chain of keys, __setitem__ that enables the underlying dict
to be set via a chain of keys, __contains__ that enables us to check for chains of keys in the underlying dict
, and __getstate__ and __setstate__ which combined enable the container to be pickled and unpickled.
x = ivy.Container(a=ivy.array([0.]), b=ivy.Container(a=ivy.array([[0.]]), b=ivy.array([1., 2., 3.])))
print(x.shape)
{
a: [
1
],
b: {
a: [
1,
1
],
b: [
3
]
}
}
print(x.ndim)
{
a: 1,
b: {
a: 2,
b: 1
}
}
num_dims = x.shape.__len__()
print(num_dims)
{
a: 1,
b: {
a: 2,
b: 1
}
}
print(len(x.shape))
# doesn't work because Python in low-level C has a restriction on the return type of `len` to be `int`
print(num_dims.real)
{
a: 1,
b: {
a: 2,
b: 1
}
}
print(bin(num_dims))
# doesn't work because some Python built-in functions have enforcement on input argument types
# external method flexibility enables positional and keyword arguments to be passed into the attribute
y = ivy.Container(l1=[1, 2, 3], c1=ivy.Container(l1=[3, 2, 1], l2=[4, 5, 6]))
print(y.__getattr__("count", 1))
{
c1: {
l1: 1,
l2: 0
},
l1: 1
}
print(y.count(1))
# doesn't work since essentially the argument 1 won't be passed to `__getattr__`
print(y.__getattr__("__add__", [10]))
{
c1: {
l1: [
3,
2,
1,
10
],
l2: [
4,
5,
6,
10
]
},
l1: [
1,
2,
3,
10
]
}
As for the special methods which are implemented in the main ivy.Container
class, they all make calls to the corresponding standard operator functions.
As a result, the operator functions will make use of the special methods of the lefthand passed input objects if available, otherwise it will make use of the reverse special method of the righthand operand.
For instance, if the lefthand operand at any given leaf of the container in an ivy.Array
, then the operator function will make calls to the special methods of this array object.
As explained in the Arrays section of the Deep Dive, these special methods will in turn call the corresponding functions from the ivy functional API.
Examples include __add__, __sub__, __mul__ and __truediv__ which will make calls to ivy.add()
, ivy.subtract()
, ivy.multiply()
and ivy.divide()
respectively if the lefthand operand is an ivy.Array
object.
Otherwise, these special methods will be called on whatever objects are at the leaves of the container, such as int, float, ivy.NativeArray
etc.
Nestable Functions#
As introduced in the Function Types section, most functions in Ivy are nestable, which means that they can accept ivy.Container
instances in place of any of the arguments.
Here, we expand on this explanation. Please check out the explanation in the Function Types section first.
Explicitly Nestable Functions
The nestable behaviour is added to any function which is decorated with the handle_nestable wrapper. This wrapper causes the function to be applied at each leaf of any containers passed in the input. More information on this can be found in the Function Wrapping section of the Deep Dive.
Additionally, any nestable function which returns multiple arrays, will return the same number of containers for its container counterpart.
This property makes the function symmetric with regards to the input-output behavior, irrespective of whether ivy.Array
or ivy.Container
instances are used.
Any argument in the input can be replaced with a container without changing the number of inputs, and the presence or absence of ivy.Container instances in the input should not change the number of return values of the function.
In other words, if containers are detected in the input, then we should return a separate container for each array that the function would otherwise return.
The current implementation checks if the leaves of the container have a list of arrays. If they do, this container is then unstacked to multiple containers(as many as the number of arrays), which are then returned inside a list.
Implicitly Nestable Functions
Compositional functions are composed of other nestable functions, and hence are already implicitly nestable. So, we do not need to explicitly wrap it at all.
Let’s take the function ivy.cross_entropy()
as an example.
The internally called functions are: ivy.clip()
, ivy.log()
, ivy.sum()
and ivy.negative()
, each of which are themselves nestable.
def cross_entropy(
true: Union[ivy.Array, ivy.NativeArray],
pred: Union[ivy.Array, ivy.NativeArray],
/,
*,
axis: Optional[int] = -1,
epsilon: float =1e-7,
out: Optional[ivy.Array] = None
) -> ivy.Array:
pred = ivy.clip(pred, epsilon, 1 - epsilon)
log_pred = ivy.log(pred)
return ivy.negative(ivy.sum(log_pred * true, axis, out=out), out=out)
Therefore, when passing an ivy.Container
instance in the input, each internal function will, in turn, correctly handle the container, and return a new container with the correct operations having been performed.
This makes it very easy and intuitive to debug the code, as the code is stepped through chronologically.
In effect, all leaves of the input container are being processed concurrently, during the computation steps of the ivy.cross_entropy()
function.
However, what if we had added the handle_nestable wrapping as a decorator directly to the function ivy.cross_entropy()
?
In this case, the ivy.cross_entropy()
function would itself be called multiple times, on each of the leaves of the container.
The functions ivy.clip()
, ivy.log()
, ivy.sum()
and ivy.negative()
would each only consume and return arrays, and debugging the ivy.cross_entropy()
function would then become less intuitively chronological, with each leaf of the input container now processed sequentially, rather than concurrently.
Therefore, our approach is to not wrap any compositional functions which are already implicitly nestable as a result of the nestable functions called internally.
Explicitly Nestable Compositional Functions
There may be some compositional functions which are not implicitly nestable for some reason, and in such cases adding the explicit handle_nestable wrapping may be necessary.
One such example is the ivy.linear()
function which is not implicitly nestable despite being compositional. This is because of the use of special functions like __len__()
and __list__()
which, among other functions, are not nestable and can’t be made nestable.
But we should try to avoid this, in order to make the flow of computation as intuitive to the user as possible.
When tracing the code, the computation graph is identical in either case, and there will be no implications on performance whatsoever. The implicit nestable solution may be slightly less efficient in eager mode, as the leaves of the container are traversed multiple times rather than once, but if performance is of concern then the code should always be traced in any case. The distinction is only really relevant when stepping through and debugging with eager mode execution, and for the reasons outlined above, the preference is to keep compositional functions implicitly nestable where possible.
Shared Nested Structure
When the nested structures of the multiple containers are shared but not identical, then the behaviour of the nestable function is a bit different. Containers have shared nested structures if all unique leaves in any of the containers are children of a nested structure which is shared by all other containers.
Take the example below, the nested structures of containers x
and y
are shared but not identical.
x = ivy.Container(a={'b': 2, 'c': 4}, d={'e': 6, 'f': 9})
y = ivy.Container(a=2, d=3)
The shared key chains (chains of keys, used for indexing the container) are a
and d
.
The key chains unique to x
are a/b
, a/c
, d/e
and d/f
.
The unique key chains all share the same base structure as all other containers (in this case only one other container, y
).
Therefore, the containers x
and y
have a shared nested structure.
When calling nestable functions on containers with non-identical structure, then the shared leaves of the shallowest container are broadcast to the leaves of the deepest container.
It’s helpful to look at an example:
print(x / y)
{
a: {
b: 1.0,
c: 2.0
},
d: {
e: 2.0,
f: 3.0
}
}
In this case, the integer at y.a
is broadcast to the leaves x.a.b
and x.a.c
, and the integer at y.d
is broadcast to the leaves x.d.e
and x.d.f
.
Another example of containers with shared nested structure is given below:
x = ivy.Container(a={'b': 2, 'c': 4}, d={'e': 6, 'f': 8})
y = ivy.Container(a=2, d=3)
z = ivy.Container(a={'b': 10, 'c': {'g': 11, 'h': 12}}, d={'e': 13, 'f': 14})
Adding these containers together would result in the following:
print(x + y + z)
{
a: {
b: 14,
c: {
g: 17,
h: 18,
}
},
d: {
e: 22,
f: 25
}
}
An example of containers which do not have a shared nested structure is given below:
x = ivy.Container(a={'b': 2, 'c': 4}, d={'e': 6, 'f': 8})
y = ivy.Container(a=2, d=3, g=4)
z = ivy.Container(a={'b': 10, 'c': {'g': 11, 'h': 12}}, d={'e': 13, 'g': 14})
This is for three reasons, (a) the key chain g
is not shared by any container other than y
, (b) the key chain d/f
for x
is not present in z
despite d
not being a non-leaf node in z
, and likewise the key chain d/g
for z
is not present in x
despite d
not being a non-leaf node in x
.
Round Up
This should have hopefully given you a good feel for containers, and how these are handled in Ivy.
If you have any questions, please feel free to reach out on discord in the containers thread!