Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets

This commit is contained in:
Kolp
2026-09-24 13:22:23 +07:00
commit 642cc11a9f
18968 changed files with 5683248 additions and 0 deletions
@@ -0,0 +1,17 @@
# imports can use EinopsError class
# ruff: noqa: E402
__author__ = "Alex Rogozhnikov"
__version__ = "0.8.2"
class EinopsError(RuntimeError):
"""Runtime error thrown by einops"""
pass # noqa: PIE790
__all__ = ["EinopsError", "asnumpy", "einsum", "pack", "parse_shape", "rearrange", "reduce", "repeat", "unpack"]
from .einops import asnumpy, einsum, parse_shape, rearrange, reduce, repeat
from .packing import pack, unpack
@@ -0,0 +1,764 @@
"""
Backends in `einops` are organized to meet the following requirements
- backends are not imported unless those are actually needed, because
- backends may not be installed
- importing all available backends will drive to significant memory footprint
- backends may be present but installed with errors (but never used),
importing may drive to crashes
- backend should be either symbolic or imperative
- this determines which methods (from_numpy/to_numpy or create_symbol/eval_symbol) should be defined
- if backend can't provide symbols for shape dimensions, UnknownSize objects are used
"""
import sys
__author__ = "Alex Rogozhnikov"
_loaded_backends: dict = {}
_type2backend: dict = {}
_debug_importing = False
def get_backend(tensor) -> "AbstractBackend":
"""
Takes a correct backend (e.g. numpy backend if tensor is numpy.ndarray) for a tensor.
If needed, imports package and creates backend
"""
_type = type(tensor)
_result = _type2backend.get(_type, None)
if _result is not None:
return _result
previously_loaded_backends = list(_loaded_backends.items())
for _framework_name, backend in previously_loaded_backends:
if backend.is_appropriate_type(tensor):
_type2backend[_type] = backend
return backend
# Find backend subclasses recursively
backend_subclasses = []
backends = AbstractBackend.__subclasses__()
while backends:
backend = backends.pop()
backends += backend.__subclasses__()
backend_subclasses.append(backend)
# handles modification of _loaded_backends from other thread, see #391
prev_backend_names = [x for x, _ in previously_loaded_backends]
for BackendSubclass in backend_subclasses:
if _debug_importing:
print("Testing for subclass of ", BackendSubclass)
if BackendSubclass.framework_name not in prev_backend_names:
# check that module was already imported. Otherwise it can't be imported
if BackendSubclass.framework_name in sys.modules:
if _debug_importing:
print("Imported backend for ", BackendSubclass.framework_name)
backend = BackendSubclass()
_loaded_backends[backend.framework_name] = backend
if backend.is_appropriate_type(tensor):
_type2backend[_type] = backend
return backend
raise RuntimeError(f"Tensor type unknown to einops {type(tensor)}")
class AbstractBackend:
"""Base backend class, major part of methods are only for debugging purposes."""
framework_name: str
def is_appropriate_type(self, tensor):
"""helper method should recognize tensors it can handle"""
raise NotImplementedError()
def from_numpy(self, x):
raise NotImplementedError("framework doesn't support imperative execution")
def to_numpy(self, x):
raise NotImplementedError("framework doesn't support imperative execution")
def create_symbol(self, shape):
raise NotImplementedError("framework doesn't support symbolic computations")
def eval_symbol(self, symbol, symbol_value_pairs):
# symbol-value pairs is list[tuple[symbol, value-tensor]]
raise NotImplementedError("framework doesn't support symbolic computations")
def arange(self, start, stop):
# supplementary method used only in testing, so should implement CPU version
raise NotImplementedError("framework doesn't implement arange")
def shape(self, x):
"""shape should return a tuple with integers or "shape symbols" (which will evaluate to actual size)"""
return x.shape
def reshape(self, x, shape):
return x.reshape(shape)
def transpose(self, x, axes):
return x.transpose(axes)
def reduce(self, x, operation, axes):
return getattr(x, operation)(axis=axes)
def stack_on_zeroth_dimension(self, tensors: list):
raise NotImplementedError()
def add_axis(self, x, new_position):
raise NotImplementedError()
def add_axes(self, x, n_axes, pos2len):
repeats = [1] * n_axes
for axis_position, axis_length in pos2len.items():
x = self.add_axis(x, axis_position)
repeats[axis_position] = axis_length
return self.tile(x, tuple(repeats))
def tile(self, x, repeats):
"""repeats - same lengths as x.shape"""
raise NotImplementedError()
def concat(self, tensors, axis: int):
"""concatenates tensors along axis.
Assume identical across tensors: devices, dtypes and shapes except selected axis."""
raise NotImplementedError()
def is_float_type(self, x):
# some backends (torch) can't compute average for non-floating types.
# Decided to drop average for all backends if type is not floating
raise NotImplementedError()
def layers(self):
raise NotImplementedError("backend does not provide layers")
def __repr__(self):
return f"<einops backend for {self.framework_name}>"
def einsum(self, pattern, *x):
raise NotImplementedError("backend does not support einsum")
class UnknownSize:
"""pseudo-symbol for symbolic frameworks which do not provide symbols for shape elements"""
def __floordiv__(self, other):
return self
def __eq__(self, other):
return True # we don't know actual size
def __mul__(self, other):
return self
def __rmul__(self, other):
return self
def __hash__(self):
return hash(None)
class NumpyBackend(AbstractBackend):
framework_name = "numpy"
def __init__(self):
import numpy
self.np = numpy
def is_appropriate_type(self, tensor):
return isinstance(tensor, self.np.ndarray)
def from_numpy(self, x):
return x
def to_numpy(self, x):
return x
def arange(self, start, stop):
return self.np.arange(start, stop)
def stack_on_zeroth_dimension(self, tensors: list):
return self.np.stack(tensors)
def tile(self, x, repeats):
return self.np.tile(x, repeats)
def concat(self, tensors, axis: int):
return self.np.concatenate(tensors, axis=axis)
def is_float_type(self, x):
return x.dtype in ("float16", "float32", "float64", "float128", "bfloat16")
def add_axis(self, x, new_position):
return self.np.expand_dims(x, new_position)
def einsum(self, pattern, *x):
return self.np.einsum(pattern, *x)
class JaxBackend(NumpyBackend):
framework_name = "jax"
def __init__(self):
super().__init__()
self.onp = self.np
import jax.numpy
self.np = jax.numpy
def from_numpy(self, x):
return self.np.asarray(x)
def to_numpy(self, x):
return self.onp.asarray(x)
class TorchBackend(AbstractBackend):
framework_name = "torch"
def __init__(self):
import torch
self.torch = torch
# importing would register operations in torch._dynamo for torch.compile
from . import _torch_specific # noqa
def is_appropriate_type(self, tensor):
return isinstance(tensor, self.torch.Tensor)
def from_numpy(self, x):
variable = self.torch.from_numpy(x)
if self.is_float_type(variable):
# attach grad only to floating types
variable.requires_grad = True
return variable
def to_numpy(self, x):
return x.detach().cpu().numpy()
def arange(self, start, stop):
return self.torch.arange(start, stop, dtype=self.torch.int64)
def reduce(self, x, operation, reduced_axes):
if operation == "min":
return x.amin(dim=reduced_axes)
elif operation == "max":
return x.amax(dim=reduced_axes)
elif operation == "sum":
return x.sum(dim=reduced_axes)
elif operation == "mean":
return x.mean(dim=reduced_axes)
elif operation in ("any", "all", "prod"):
# pytorch supports reducing only one operation at a time
for i in sorted(reduced_axes)[::-1]:
x = getattr(x, operation)(dim=i)
return x
else:
raise NotImplementedError("Unknown reduction ", operation)
def transpose(self, x, axes):
return x.permute(axes)
def stack_on_zeroth_dimension(self, tensors: list):
return self.torch.stack(tensors)
def add_axes(self, x, n_axes, pos2len):
repeats = [-1] * n_axes
for axis_position, axis_length in pos2len.items():
x = self.add_axis(x, axis_position)
repeats[axis_position] = axis_length
return x.expand(repeats)
def tile(self, x, repeats):
return x.repeat(repeats)
def concat(self, tensors, axis: int):
return self.torch.cat(tensors, dim=axis)
def add_axis(self, x, new_position):
return self.torch.unsqueeze(x, new_position)
def is_float_type(self, x):
return x.dtype in [self.torch.float16, self.torch.float32, self.torch.float64, self.torch.bfloat16]
def layers(self):
from .layers import torch
return torch
def einsum(self, pattern, *x):
return self.torch.einsum(pattern, *x)
class CupyBackend(AbstractBackend):
framework_name = "cupy"
def __init__(self):
import cupy
self.cupy = cupy
def is_appropriate_type(self, tensor):
return isinstance(tensor, self.cupy.ndarray)
def from_numpy(self, x):
return self.cupy.asarray(x)
def to_numpy(self, x):
return self.cupy.asnumpy(x)
def arange(self, start, stop):
return self.cupy.arange(start, stop)
def stack_on_zeroth_dimension(self, tensors: list):
return self.cupy.stack(tensors)
def tile(self, x, repeats):
return self.cupy.tile(x, repeats)
def concat(self, tensors, axis: int):
return self.cupy.concatenate(tensors, axis=axis)
def add_axis(self, x, new_position):
return self.cupy.expand_dims(x, new_position)
def is_float_type(self, x):
return x.dtype in ("float16", "float32", "float64", "float128", "bfloat16")
def einsum(self, pattern, *x):
return self.cupy.einsum(pattern, *x)
class HashableTuple:
"""Overcomes non-hashability of symbolic elements"""
def __init__(self, elements: tuple):
self.elements = elements
def __iter__(self):
yield from self.elements
def __len__(self):
return len(self.elements)
def __getitem__(self, item):
return self.elements[item]
# default equality and hash is used (True only with itself, hash taken of id)
class TensorflowBackend(AbstractBackend):
framework_name = "tensorflow"
def __init__(self):
import tensorflow
self.tf = tensorflow
def is_appropriate_type(self, tensor):
return isinstance(tensor, (self.tf.Tensor, self.tf.Variable))
def from_numpy(self, x):
assert self.tf.executing_eagerly()
return self.tf.convert_to_tensor(x)
def to_numpy(self, x):
assert self.tf.executing_eagerly()
return x.numpy()
def arange(self, start, stop):
return self.tf.range(start, stop)
def shape(self, x):
if self.tf.executing_eagerly():
return tuple(UnknownSize() if d is None else int(d) for d in x.shape)
else:
static_shape = x.shape.as_list()
tf_shape = self.tf.shape(x)
# use the static shape where known, otherwise use the TF shape components
shape = tuple([s or tf_shape[dim] for dim, s in enumerate(static_shape)])
try:
hash(shape)
return shape
except BaseException:
# unhashable symbols in shape. Wrap tuple to be hashable.
return HashableTuple(shape)
def reduce(self, x, operation, axes):
return getattr(self.tf, "reduce_" + operation)(x, axis=axes)
def reshape(self, x, shape):
return self.tf.reshape(x, shape)
def transpose(self, x, axes):
return self.tf.transpose(x, axes)
def stack_on_zeroth_dimension(self, tensors: list):
return self.tf.stack(tensors)
def tile(self, x, repeats):
return self.tf.tile(x, repeats)
def concat(self, tensors, axis: int):
return self.tf.concat(tensors, axis=axis)
def add_axis(self, x, new_position):
return self.tf.expand_dims(x, new_position)
def is_float_type(self, x):
return x.dtype in ("float16", "float32", "float64", "float128", "bfloat16")
def layers(self):
from .layers import tensorflow
return tensorflow
def einsum(self, pattern, *x):
return self.tf.einsum(pattern, *x)
class TFKerasBackend(AbstractBackend):
framework_name = "tensorflow.keras"
def __init__(self):
import tensorflow as tf
self.tf = tf
self.keras = tf.keras
self.K = tf.keras.backend
def is_appropriate_type(self, tensor):
return self.tf.is_tensor(tensor) and self.K.is_keras_tensor(tensor)
def create_symbol(self, shape):
return self.keras.Input(batch_shape=shape)
def eval_symbol(self, symbol, symbol_value_pairs):
model = self.keras.models.Model([var for (var, _) in symbol_value_pairs], symbol)
return model.predict_on_batch([val for (_, val) in symbol_value_pairs])
def arange(self, start, stop):
return self.K.arange(start, stop)
def shape(self, x):
shape = self.K.shape(x) # tf tensor
return HashableTuple(tuple(shape))
def reduce(self, x, operation, axes):
return getattr(self.K, operation)(x, axis=axes)
def reshape(self, x, shape):
return self.K.reshape(x, shape)
def transpose(self, x, axes):
return self.K.permute_dimensions(x, axes)
def stack_on_zeroth_dimension(self, tensors: list):
return self.K.stack(tensors)
def tile(self, x, repeats):
return self.K.tile(x, repeats)
def concat(self, tensors, axis: int):
return self.K.concatenate(tensors, axis=axis)
def add_axis(self, x, new_position):
return self.K.expand_dims(x, new_position)
def is_float_type(self, x):
return "float" in self.K.dtype(x)
def layers(self):
from .layers import keras
return keras
class OneFlowBackend(AbstractBackend):
framework_name = "oneflow"
def __init__(self):
import oneflow as flow
self.flow = flow
def is_appropriate_type(self, tensor):
return isinstance(tensor, self.flow.Tensor)
def from_numpy(self, x):
variable = self.flow.from_numpy(x)
if self.is_float_type(variable):
# attach grad only to floating types
variable.requires_grad = True
return variable
def to_numpy(self, x):
return x.detach().cpu().numpy()
def arange(self, start, stop):
return self.flow.arange(start, stop, dtype=self.flow.int64)
def reduce(self, x, operation, reduced_axes):
for axis in sorted(reduced_axes, reverse=True):
if operation == "min":
x, _ = x.min(dim=axis)
elif operation == "max":
x, _ = x.max(dim=axis)
elif operation in ["sum", "mean", "prod", "any", "all"]:
x = getattr(x, operation)(dim=axis)
else:
raise NotImplementedError("Unknown reduction ", operation)
return x
def transpose(self, x, axes):
return x.permute(axes)
def stack_on_zeroth_dimension(self, tensors: list):
return self.flow.stack(tensors)
def add_axes(self, x, n_axes, pos2len):
repeats = [-1] * n_axes
for axis_position, axis_length in pos2len.items():
x = self.add_axis(x, axis_position)
repeats[axis_position] = axis_length
return x.expand(*repeats)
def tile(self, x, repeats):
return x.repeat(repeats)
def concat(self, tensors, axis: int):
return self.flow.concat(tensors, dim=axis)
def add_axis(self, x, new_position):
return self.flow.unsqueeze(x, new_position)
def is_float_type(self, x):
return x.dtype in [self.flow.float16, self.flow.float32, self.flow.float64]
def layers(self):
from .layers import oneflow
return oneflow
def einsum(self, pattern, *x):
return self.flow.einsum(pattern, *x)
class PaddleBackend(AbstractBackend):
framework_name = "paddle"
def __init__(self):
import paddle
self.paddle = paddle
def is_appropriate_type(self, tensor):
return self.paddle.is_tensor(tensor)
def from_numpy(self, x):
tensor = self.paddle.to_tensor(x)
tensor.stop_gradient = False
return tensor
def to_numpy(self, x):
return x.detach().numpy()
def arange(self, start, stop):
return self.paddle.arange(start, stop, dtype=self.paddle.int64)
def reduce(self, x, operation, axes):
if len(axes) == x.ndim:
# currently paddle returns 1d tensor instead of 0d
return super().reduce(x, operation, axes).squeeze(0)
else:
return super().reduce(x, operation, axes)
def transpose(self, x, axes):
return x.transpose(axes)
def add_axes(self, x, n_axes, pos2len):
repeats = [-1] * n_axes
for axis_position, axis_length in pos2len.items():
x = self.add_axis(x, axis_position)
repeats[axis_position] = axis_length
return x.expand(repeats)
def stack_on_zeroth_dimension(self, tensors: list):
return self.paddle.stack(tensors)
def reshape(self, x, shape):
return x.reshape(shape)
def tile(self, x, repeats):
return x.tile(repeats)
def concat(self, tensors, axis: int):
return self.paddle.concat(tensors, axis=axis)
def add_axis(self, x, new_position):
return x.unsqueeze(new_position)
def is_float_type(self, x):
return x.dtype in [self.paddle.float16, self.paddle.float32, self.paddle.float64]
def layers(self):
from .layers import paddle
return paddle
def einsum(self, pattern, *x):
return self.paddle.einsum(pattern, *x)
def shape(self, x):
return tuple(x.shape)
class TinygradBackend(AbstractBackend):
framework_name = "tinygrad"
def __init__(self):
import tinygrad
self.tinygrad = tinygrad
def is_appropriate_type(self, tensor):
return isinstance(tensor, self.tinygrad.Tensor)
def from_numpy(self, x):
return self.tinygrad.Tensor(x)
def to_numpy(self, x):
return x.numpy()
def arange(self, start, stop):
return self.tinygrad.Tensor.arange(start, stop)
def shape(self, x):
return x.shape
def reshape(self, x, shape):
return x.reshape(shape)
def transpose(self, x, axes):
return x.permute(axes)
def reduce(self, x, operation, axes):
for axis in sorted(axes, reverse=True):
x = getattr(x, operation)(axis=axis)
return x
def stack_on_zeroth_dimension(self, tensors: list):
return self.tinygrad.Tensor.stack(tensors)
def add_axis(self, x, new_position):
return x.unsqueeze(new_position)
def tile(self, x, repeats):
return x.repeat(repeats)
def concat(self, tensors, axis: int):
return tensors[0].cat(*tensors[1:], dim=axis) if len(tensors) > 1 else tensors[0]
def is_float_type(self, x):
return self.tinygrad.dtypes.is_float(x.dtype)
def einsum(self, pattern, *x):
return self.tinygrad.Tensor.einsum(pattern, *x)
class PyTensorBackend(AbstractBackend):
framework_name = "pytensor"
def __init__(self):
from pytensor import tensor
self.pt = tensor
def is_appropriate_type(self, tensor):
return isinstance(tensor, self.pt.TensorVariable)
def is_float_type(self, x):
return x.dtype in self.pt.type.float_dtypes
def from_numpy(self, x):
return self.pt.as_tensor(x)
def to_numpy(self, x):
return x.eval() # Will only work if there are no symbolic inputs
def create_symbol(self, shape):
if not isinstance(shape, tuple | list):
shape = (shape,)
return self.pt.tensor(shape=shape)
def eval_symbol(self, symbol, symbol_value_pairs):
return symbol.eval(dict(symbol_value_pairs))
def arange(self, start, stop):
return self.pt.arange(start, stop)
def shape(self, x):
# use the static shape dimensions where known
return tuple(
static_dim if static_dim is not None else symbolic_dim
for static_dim, symbolic_dim in zip(x.type.shape, x.shape)
)
def stack_on_zeroth_dimension(self, tensors: list):
return self.pt.stack(tensors)
def tile(self, x, repeats):
return self.pt.tile(x, repeats)
def concat(self, tensors, axis: int):
return self.pt.concatenate(tensors, axis=axis)
def add_axis(self, x, new_position):
return self.pt.expand_dims(x, new_position)
def einsum(self, pattern, *x):
return self.pt.einsum(pattern, *x)
class MLXBackend(AbstractBackend):
framework_name = "mlx"
def __init__(self):
import mlx.core as mx
import numpy as np
self.mx = mx
self.np = np
def is_appropriate_type(self, tensor):
return isinstance(tensor, self.mx.array)
def from_numpy(self, x):
return self.mx.array(x)
def to_numpy(self, x):
if x.dtype == self.mx.bfloat16:
x = x.astype(self.mx.float32)
return self.np.array(x)
def arange(self, start, stop):
return self.mx.arange(start, stop)
def stack_on_zeroth_dimension(self, tensors: list):
return self.mx.stack(tensors)
def add_axes(self, x, new_position):
return self.mx.expand_dims(x, new_position)
def tile(self, x, repeats):
return self.mx.tile(x, repeats)
def concat(self, tensors, axis: int):
return self.mx.concatenate(tensors, axis=axis)
def is_float_type(self, x):
return self.mx.issubdtype(x.dtype, self.mx.floating)
def einsum(self, pattern, *x):
return self.mx.einsum(pattern, *x)
@@ -0,0 +1,136 @@
"""
Specialization of einops for torch.
Unfortunately, torch's jit scripting mechanism isn't strong enough,
and to have scripting supported at least for layers,
a number of additional moves is needed.
Design of main operations (dynamic resolution by lookup) is unlikely
to be implemented by torch.jit.script,
but torch.compile seems to work with operations just fine.
"""
import warnings
from typing import Dict, List, Tuple
import torch
from einops.einops import TransformRecipe, _reconstruct_from_shape_uncached
class TorchJitBackend:
"""
Completely static backend that mimics part of normal backend functionality
but restricted to be within torchscript.
"""
@staticmethod
def reduce(x: torch.Tensor, operation: str, reduced_axes: List[int]):
if operation == "min":
return x.amin(dim=reduced_axes)
elif operation == "max":
return x.amax(dim=reduced_axes)
elif operation == "sum":
return x.sum(dim=reduced_axes)
elif operation == "mean":
return x.mean(dim=reduced_axes)
elif operation == "prod":
for i in sorted(reduced_axes)[::-1]:
x = x.prod(dim=i)
return x
else:
raise NotImplementedError("Unknown reduction ", operation)
@staticmethod
def transpose(x, axes: List[int]):
return x.permute(axes)
@staticmethod
def stack_on_zeroth_dimension(tensors: List[torch.Tensor]):
return torch.stack(tensors)
@staticmethod
def tile(x, repeats: List[int]):
return x.repeat(repeats)
@staticmethod
def add_axes(x, n_axes: int, pos2len: Dict[int, int]):
repeats = [-1] * n_axes
for axis_position, axis_length in pos2len.items():
x = torch.unsqueeze(x, axis_position)
repeats[axis_position] = axis_length
return x.expand(repeats)
@staticmethod
def is_float_type(x):
return x.dtype in [torch.float16, torch.float32, torch.float64, torch.bfloat16]
@staticmethod
def shape(x):
return x.shape
@staticmethod
def reshape(x, shape: List[int]):
return x.reshape(shape)
# mirrors einops.einops._apply_recipe
def apply_for_scriptable_torch(
recipe: TransformRecipe, tensor: torch.Tensor, reduction_type: str, axes_dims: List[Tuple[str, int]]
) -> torch.Tensor:
backend = TorchJitBackend
(
init_shapes,
axes_reordering,
reduced_axes,
added_axes,
final_shapes,
n_axes_w_added,
) = _reconstruct_from_shape_uncached(recipe, backend.shape(tensor), axes_dims=axes_dims)
if init_shapes is not None:
tensor = backend.reshape(tensor, init_shapes)
if axes_reordering is not None:
tensor = backend.transpose(tensor, axes_reordering)
if len(reduced_axes) > 0:
tensor = backend.reduce(tensor, operation=reduction_type, reduced_axes=reduced_axes)
if len(added_axes) > 0:
tensor = backend.add_axes(tensor, n_axes=n_axes_w_added, pos2len=added_axes)
if final_shapes is not None:
tensor = backend.reshape(tensor, final_shapes)
return tensor
def allow_ops_in_compiled_graph():
if hasattr(torch, "__version__") and torch.__version__[0] < "2":
# torch._dynamo and torch.compile appear in pytorch 2.0
return
if hasattr(torch, "__version__") and torch.__version__ >= "2.8":
# einops don't need to use allow_in graph for torch 2.8 and above
return
try:
from torch._dynamo import allow_in_graph
except ImportError:
warnings.warn(
"allow_ops_in_compiled_graph failed to import torch: ensure pytorch >=2.0", ImportWarning, stacklevel=1
)
return
from .einops import einsum, rearrange, reduce, repeat
from .packing import pack, unpack
allow_in_graph(rearrange)
allow_in_graph(reduce)
allow_in_graph(repeat)
allow_in_graph(einsum)
allow_in_graph(pack)
allow_in_graph(unpack)
# CF: https://github.com/pytorch/pytorch/blob/2df939aacac68e9621fbd5d876c78d86e72b41e2/torch/_dynamo/__init__.py#L222
global _ops_were_registered_in_torchdynamo
_ops_were_registered_in_torchdynamo = True
# module import automatically registers ops in torchdynamo
allow_ops_in_compiled_graph()
@@ -0,0 +1,125 @@
from typing import List, Sequence, Tuple
from .einops import EinopsError, Reduction, Tensor, _apply_recipe_array_api, _prepare_transformation_recipe
from .packing import analyze_pattern, prod
def reduce(tensor: Tensor, pattern: str, reduction: Reduction, **axes_lengths: int) -> Tensor:
if isinstance(tensor, list):
if len(tensor) == 0:
raise TypeError("Einops can't be applied to an empty list")
xp = tensor[0].__array_namespace__()
tensor = xp.stack(tensor)
else:
xp = tensor.__array_namespace__()
try:
hashable_axes_lengths = tuple(axes_lengths.items())
recipe = _prepare_transformation_recipe(pattern, reduction, axes_names=tuple(axes_lengths), ndim=tensor.ndim)
return _apply_recipe_array_api(
xp,
recipe=recipe,
tensor=tensor,
reduction_type=reduction,
axes_lengths=hashable_axes_lengths,
)
except EinopsError as e:
message = f' Error while processing {reduction}-reduction pattern "{pattern}".'
if not isinstance(tensor, list):
message += f"\n Input tensor shape: {tensor.shape}. "
else:
message += "\n Input is list. "
message += f"Additional info: {axes_lengths}."
raise EinopsError(message + f"\n {e}") from None
def repeat(tensor: Tensor, pattern: str, **axes_lengths) -> Tensor:
return reduce(tensor, pattern, reduction="repeat", **axes_lengths)
def rearrange(tensor: Tensor, pattern: str, **axes_lengths) -> Tensor:
return reduce(tensor, pattern, reduction="rearrange", **axes_lengths)
def asnumpy(tensor: Tensor):
import numpy as np
return np.from_dlpack(tensor)
Shape = Tuple
def pack(tensors: Sequence[Tensor], pattern: str) -> Tuple[Tensor, List[Shape]]:
n_axes_before, n_axes_after, min_axes = analyze_pattern(pattern, "pack")
xp = tensors[0].__array_namespace__()
reshaped_tensors: List[Tensor] = []
packed_shapes: List[Shape] = []
for i, tensor in enumerate(tensors):
shape = tensor.shape
if len(shape) < min_axes:
raise EinopsError(
f"packed tensor #{i} (enumeration starts with 0) has shape {shape}, "
f"while pattern {pattern} assumes at least {min_axes} axes"
)
axis_after_packed_axes = len(shape) - n_axes_after
packed_shapes.append(shape[n_axes_before:axis_after_packed_axes])
reshaped_tensors.append(xp.reshape(tensor, (*shape[:n_axes_before], -1, *shape[axis_after_packed_axes:])))
return xp.concat(reshaped_tensors, axis=n_axes_before), packed_shapes
def unpack(tensor: Tensor, packed_shapes: List[Shape], pattern: str) -> List[Tensor]:
xp = tensor.__array_namespace__()
n_axes_before, n_axes_after, min_axes = analyze_pattern(pattern, opname="unpack")
# backend = get_backend(tensor)
input_shape = tensor.shape
if len(input_shape) != n_axes_before + 1 + n_axes_after:
raise EinopsError(f"unpack(..., {pattern}) received input of wrong dim with shape {input_shape}")
unpacked_axis: int = n_axes_before
lengths_of_composed_axes: List[int] = [-1 if -1 in p_shape else prod(p_shape) for p_shape in packed_shapes]
n_unknown_composed_axes = sum(x == -1 for x in lengths_of_composed_axes)
if n_unknown_composed_axes > 1:
raise EinopsError(
f"unpack(..., {pattern}) received more than one -1 in {packed_shapes} and can't infer dimensions"
)
# following manipulations allow to skip some shape verifications
# and leave it to backends
# [[], [2, 3], [4], [-1, 5], [6]] < examples of packed_axis
# split positions when computed should be
# [0, 1, 7, 11, N-6 , N ], where N = length of axis
split_positions = [0] * len(packed_shapes) + [input_shape[unpacked_axis]]
if n_unknown_composed_axes == 0:
for i, x in enumerate(lengths_of_composed_axes[:-1]):
split_positions[i + 1] = split_positions[i] + x
else:
unknown_composed_axis: int = lengths_of_composed_axes.index(-1)
for i in range(unknown_composed_axis):
split_positions[i + 1] = split_positions[i] + lengths_of_composed_axes[i]
for j in range(unknown_composed_axis + 1, len(lengths_of_composed_axes))[::-1]:
split_positions[j] = split_positions[j + 1] - lengths_of_composed_axes[j]
shape_start = input_shape[:unpacked_axis]
shape_end = input_shape[unpacked_axis + 1 :]
slice_filler = (slice(None, None),) * unpacked_axis
try:
return [
xp.reshape(
# shortest way slice arbitrary axis
tensor[(*slice_filler, slice(split_positions[i], split_positions[i + 1]), ...)],
(*shape_start, *element_shape, *shape_end),
)
for i, element_shape in enumerate(packed_shapes)
]
except Exception as e:
# this hits if there is an error during reshapes, which means passed shapes were incorrect
raise RuntimeError(
f'Error during unpack(..., "{pattern}"): could not split axis of size {split_positions[-1]}'
f" into requested {packed_shapes}"
) from e
@@ -0,0 +1,939 @@
import functools
import itertools
import string
import typing
from collections import OrderedDict
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, TypeVar, Union, cast, overload
if typing.TYPE_CHECKING:
# for docstrings in pycharm
import numpy as np # noqa E401
from . import EinopsError
from ._backends import get_backend
from .parsing import AnonymousAxis, ParsedExpression, _ellipsis
Tensor = TypeVar("Tensor")
ReductionCallable = Callable[[Tensor, Tuple[int, ...]], Tensor]
Reduction = Union[str, ReductionCallable]
Size = typing.Any
_reductions = ("min", "max", "sum", "mean", "prod", "any", "all")
# magic integers are required to stay within
# traceable subset of language
_unknown_axis_length = -999999
_expected_axis_length = -99999
def _product(sequence: List[int]) -> int:
"""minimalistic product that works both with numbers and symbols. Supports empty lists"""
result = 1
for element in sequence:
result *= element
return result
def _reduce_axes(tensor, reduction_type: Reduction, reduced_axes: List[int], backend):
if callable(reduction_type):
# custom callable
return reduction_type(tensor, tuple(reduced_axes))
else:
# one of built-in operations
assert reduction_type in _reductions
if reduction_type == "mean":
if not backend.is_float_type(tensor):
raise NotImplementedError("reduce_mean is not available for non-floating tensors")
return backend.reduce(tensor, reduction_type, tuple(reduced_axes))
def _optimize_transformation(init_shapes, reduced_axes, axes_reordering, final_shapes):
# 'collapses' neighboring axes if those participate in the result pattern in the same order
# TODO add support for added_axes
assert len(axes_reordering) + len(reduced_axes) == len(init_shapes)
# joining consecutive axes that will be reduced
# possibly we can skip this if all backends can optimize this (not sure)
reduced_axes = tuple(sorted(reduced_axes))
for i in range(len(reduced_axes) - 1)[::-1]:
if reduced_axes[i] + 1 == reduced_axes[i + 1]:
removed_axis = reduced_axes[i + 1]
removed_length = init_shapes[removed_axis]
init_shapes = init_shapes[:removed_axis] + init_shapes[removed_axis + 1 :]
init_shapes[removed_axis - 1] *= removed_length
reduced_axes = reduced_axes[: i + 1] + tuple(axis - 1 for axis in reduced_axes[i + 2 :])
# removing axes that are moved together during reshape
def build_mapping():
init_to_final = {}
for axis in range(len(init_shapes)):
if axis in reduced_axes:
init_to_final[axis] = None
else:
after_reduction = sum(x is not None for x in init_to_final.values())
init_to_final[axis] = list(axes_reordering).index(after_reduction)
return init_to_final
init_axis_to_final_axis = build_mapping()
for init_axis in range(len(init_shapes) - 1)[::-1]:
if init_axis_to_final_axis[init_axis] is None:
continue
if init_axis_to_final_axis[init_axis + 1] is None:
continue
if init_axis_to_final_axis[init_axis] + 1 == init_axis_to_final_axis[init_axis + 1]:
removed_axis = init_axis + 1
removed_length = init_shapes[removed_axis]
removed_axis_after_reduction = sum(x not in reduced_axes for x in range(removed_axis))
reduced_axes = tuple(axis if axis < removed_axis else axis - 1 for axis in reduced_axes)
init_shapes = init_shapes[:removed_axis] + init_shapes[removed_axis + 1 :]
init_shapes[removed_axis - 1] *= removed_length
old_reordering = axes_reordering
axes_reordering = []
for axis in old_reordering:
if axis == removed_axis_after_reduction:
pass
elif axis < removed_axis_after_reduction:
axes_reordering.append(axis)
else:
axes_reordering.append(axis - 1)
init_axis_to_final_axis = build_mapping()
return init_shapes, reduced_axes, axes_reordering, final_shapes
CookedRecipe = Tuple[Optional[List[int]], Optional[List[int]], List[int], Dict[int, int], Optional[List[int]], int]
# Actual type is tuple[tuple[str, int], ...]
# However torch.jit.script does not "understand" the correct type,
# and torch_specific will use list version.
HashableAxesLengths = Tuple[Tuple[str, int], ...]
FakeHashableAxesLengths = List[Tuple[str, int]]
class TransformRecipe:
"""
Recipe describes actual computation pathway.
Recipe can be applied to a tensor or variable.
"""
# structure is non-mutable. In future, this can be non-mutable dataclass (python 3.7+)
# update: pytorch 2.0 torch.jit.script seems to have problems with dataclasses unless they were explicitly provided
def __init__(
self,
# list of sizes (or just sizes) for elementary axes as they appear in left expression.
# this is what (after computing unknown parts) will be a shape after first transposition.
# This does not include any ellipsis dimensions.
elementary_axes_lengths: List[int],
# if additional axes are provided, they should be set in prev array
# This shows mapping from name to position
axis_name2elementary_axis: Dict[str, int],
# each dimension in input can help to reconstruct length of one elementary axis
# or verify one of dimensions. Each element points to element of elementary_axes_lengths.
input_composition_known_unknown: List[Tuple[List[int], List[int]]],
# permutation applied to elementary axes, if ellipsis is absent
axes_permutation: List[int],
# permutation puts reduced axes in the end, we only need to know the first position.
first_reduced_axis: int,
# at which positions which of elementary axes should appear. Axis position -> axis index.
added_axes: Dict[int, int],
# ids of axes as they appear in result, again pointers to elementary_axes_lengths,
# only used to infer result dimensions
output_composite_axes: List[List[int]],
):
self.elementary_axes_lengths: List[int] = elementary_axes_lengths
self.axis_name2elementary_axis: Dict[str, int] = axis_name2elementary_axis
self.input_composition_known_unknown: List[Tuple[List[int], List[int]]] = input_composition_known_unknown
self.axes_permutation: List[int] = axes_permutation
self.first_reduced_axis: int = first_reduced_axis
self.added_axes: Dict[int, int] = added_axes
self.output_composite_axes: List[List[int]] = output_composite_axes
def _reconstruct_from_shape_uncached(
self: TransformRecipe, shape: List[int], axes_dims: FakeHashableAxesLengths
) -> CookedRecipe:
"""
Reconstruct all actual parameters using shape.
Shape is a tuple that may contain integers, shape symbols (tf, theano) and UnknownSize (tf, previously mxnet)
known axes can be integers or symbols, but not Nones.
"""
# magic number
need_init_reshape = False
# last axis is allocated for collapsed ellipsis
axes_lengths: List[int] = list(self.elementary_axes_lengths)
for axis, dim in axes_dims:
axes_lengths[self.axis_name2elementary_axis[axis]] = dim
for input_axis, (known_axes, unknown_axes) in enumerate(self.input_composition_known_unknown):
length = shape[input_axis]
if len(known_axes) == 0 and len(unknown_axes) == 1:
# shortcut for the most common case
axes_lengths[unknown_axes[0]] = length
continue
known_product = 1
for axis in known_axes:
known_product *= axes_lengths[axis]
if len(unknown_axes) == 0:
if isinstance(length, int) and isinstance(known_product, int) and length != known_product:
raise EinopsError(f"Shape mismatch, {length} != {known_product}")
else:
# assert len(unknown_axes) == 1, 'this is enforced when recipe is created, so commented out'
if isinstance(length, int) and isinstance(known_product, int) and length % known_product != 0:
raise EinopsError(f"Shape mismatch, can't divide axis of length {length} in chunks of {known_product}")
unknown_axis = unknown_axes[0]
inferred_length: int = length // known_product
axes_lengths[unknown_axis] = inferred_length
if len(known_axes) + len(unknown_axes) != 1:
need_init_reshape = True
# at this point all axes_lengths are computed (either have values or variables, but not Nones)
# elementary axes are ordered as they appear in input, then all added axes
init_shapes: Optional[List[int]] = axes_lengths[: len(self.axes_permutation)] if need_init_reshape else None
need_final_reshape = False
final_shapes: List[int] = []
for grouping in self.output_composite_axes:
lengths = [axes_lengths[elementary_axis] for elementary_axis in grouping]
final_shapes.append(_product(lengths))
if len(lengths) != 1:
need_final_reshape = True
added_axes: Dict[int, int] = {
pos: axes_lengths[pos_in_elementary] for pos, pos_in_elementary in self.added_axes.items()
}
# this list can be empty
reduced_axes = list(range(self.first_reduced_axis, len(self.axes_permutation)))
n_axes_after_adding_axes = len(added_axes) + len(self.axes_permutation)
axes_reordering: Optional[List[int]] = self.axes_permutation
if self.axes_permutation == list(range(len(self.axes_permutation))):
axes_reordering = None
_final_shapes = final_shapes if need_final_reshape else None
return init_shapes, axes_reordering, reduced_axes, added_axes, _final_shapes, n_axes_after_adding_axes
_reconstruct_from_shape = functools.lru_cache(1024)(_reconstruct_from_shape_uncached)
def _apply_recipe(
backend, recipe: TransformRecipe, tensor: Tensor, reduction_type: Reduction, axes_lengths: HashableAxesLengths
) -> Tensor:
# this method implements actual work for all backends for 3 operations
try:
init_shapes, axes_reordering, reduced_axes, added_axes, final_shapes, n_axes_w_added = _reconstruct_from_shape(
recipe, backend.shape(tensor), axes_lengths
)
except TypeError:
# shape or one of passed axes lengths is not hashable (i.e. they are symbols)
_result = _reconstruct_from_shape_uncached(recipe, backend.shape(tensor), axes_lengths)
(init_shapes, axes_reordering, reduced_axes, added_axes, final_shapes, n_axes_w_added) = _result
if init_shapes is not None:
tensor = backend.reshape(tensor, init_shapes)
if axes_reordering is not None:
tensor = backend.transpose(tensor, axes_reordering)
if len(reduced_axes) > 0:
tensor = _reduce_axes(tensor, reduction_type=reduction_type, reduced_axes=reduced_axes, backend=backend)
if len(added_axes) > 0:
tensor = backend.add_axes(tensor, n_axes=n_axes_w_added, pos2len=added_axes)
if final_shapes is not None:
tensor = backend.reshape(tensor, final_shapes)
return tensor
def _apply_recipe_array_api(
xp, recipe: TransformRecipe, tensor: Tensor, reduction_type: Reduction, axes_lengths: HashableAxesLengths
) -> Tensor:
# completely-inline implementation
init_shapes, axes_reordering, reduced_axes, added_axes, final_shapes, n_axes_w_added = _reconstruct_from_shape(
recipe, tensor.shape, axes_lengths
)
if init_shapes is not None:
tensor = xp.reshape(tensor, init_shapes)
if axes_reordering is not None:
tensor = xp.permute_dims(tensor, axes_reordering)
if len(reduced_axes) > 0:
if callable(reduction_type):
# custom callable
tensor = reduction_type(tensor, tuple(reduced_axes))
else:
# one of built-in operations
assert reduction_type in _reductions
tensor = getattr(xp, reduction_type)(tensor, axis=tuple(reduced_axes))
if len(added_axes) > 0:
# we use broadcasting
for axis_position, _axis_length in added_axes.items():
tensor = xp.expand_dims(tensor, axis=axis_position)
final_shape = list(tensor.shape)
for axis_position, axis_length in added_axes.items():
final_shape[axis_position] = axis_length
tensor = xp.broadcast_to(tensor, final_shape)
if final_shapes is not None:
tensor = xp.reshape(tensor, final_shapes)
return tensor
@functools.lru_cache(256)
def _prepare_transformation_recipe(
pattern: str,
operation: Reduction,
axes_names: Tuple[str, ...],
ndim: int,
) -> TransformRecipe:
"""Perform initial parsing of pattern and provided supplementary info
axes_lengths is a tuple of tuples (axis_name, axis_length)
"""
left_str, rght_str = pattern.split("->")
left = ParsedExpression(left_str)
rght = ParsedExpression(rght_str)
# checking that axes are in agreement - new axes appear only in repeat, while disappear only in reduction
if not left.has_ellipsis and rght.has_ellipsis:
raise EinopsError(f"Ellipsis found in right side, but not left side of a pattern {pattern}")
if left.has_ellipsis and left.has_ellipsis_parenthesized:
raise EinopsError(f"Ellipsis inside parenthesis in the left side is not allowed: {pattern}")
if operation == "rearrange":
if left.has_non_unitary_anonymous_axes or rght.has_non_unitary_anonymous_axes:
raise EinopsError("Non-unitary anonymous axes are not supported in rearrange (exception is length 1)")
difference = set.symmetric_difference(left.identifiers, rght.identifiers)
if len(difference) > 0:
raise EinopsError(f"Identifiers only on one side of expression (should be on both): {difference}")
elif operation == "repeat":
difference = set.difference(left.identifiers, rght.identifiers)
if len(difference) > 0:
raise EinopsError(f"Unexpected identifiers on the left side of repeat: {difference}")
axes_without_size = set.difference(
{ax for ax in rght.identifiers if not isinstance(ax, AnonymousAxis)},
{*left.identifiers, *axes_names},
)
if len(axes_without_size) > 0:
raise EinopsError(f"Specify sizes for new axes in repeat: {axes_without_size}")
elif operation in _reductions or callable(operation):
difference = set.difference(rght.identifiers, left.identifiers)
if len(difference) > 0:
raise EinopsError(f"Unexpected identifiers on the right side of reduce {operation}: {difference}")
else:
raise EinopsError(f"Unknown reduction {operation}. Expect one of {_reductions}.")
if left.has_ellipsis:
n_other_dims = len(left.composition) - 1
if ndim < n_other_dims:
raise EinopsError(f"Wrong shape: expected >={n_other_dims} dims. Received {ndim}-dim tensor.")
ellipsis_ndim = ndim - n_other_dims
ell_axes = [_ellipsis + str(i) for i in range(ellipsis_ndim)]
left_composition = []
for composite_axis in left.composition:
if composite_axis == _ellipsis:
for axis in ell_axes:
left_composition.append([axis])
else:
left_composition.append(composite_axis)
rght_composition = []
for composite_axis in rght.composition:
if composite_axis == _ellipsis:
for axis in ell_axes:
rght_composition.append([axis])
else:
group = []
for axis in composite_axis:
if axis == _ellipsis:
group.extend(ell_axes)
else:
group.append(axis)
rght_composition.append(group)
left.identifiers.update(ell_axes)
left.identifiers.remove(_ellipsis)
if rght.has_ellipsis:
rght.identifiers.update(ell_axes)
rght.identifiers.remove(_ellipsis)
else:
if ndim != len(left.composition):
raise EinopsError(f"Wrong shape: expected {len(left.composition)} dims. Received {ndim}-dim tensor.")
left_composition = left.composition
rght_composition = rght.composition
# parsing all dimensions to find out lengths
axis_name2known_length: Dict[Union[str, AnonymousAxis], int] = OrderedDict()
for composite_axis in left_composition:
for axis_name in composite_axis:
if isinstance(axis_name, AnonymousAxis):
axis_name2known_length[axis_name] = axis_name.value
else:
axis_name2known_length[axis_name] = _unknown_axis_length
# axis_ids_after_first_reshape = range(len(axis_name2known_length)) at this point
repeat_axes_names = []
for axis_name in rght.identifiers:
if axis_name not in axis_name2known_length:
if isinstance(axis_name, AnonymousAxis):
axis_name2known_length[axis_name] = axis_name.value
else:
axis_name2known_length[axis_name] = _unknown_axis_length
repeat_axes_names.append(axis_name)
axis_name2position = {name: position for position, name in enumerate(axis_name2known_length)}
# axes provided as kwargs
for elementary_axis in axes_names:
if not ParsedExpression.check_axis_name(elementary_axis):
raise EinopsError("Invalid name for an axis", elementary_axis)
if elementary_axis not in axis_name2known_length:
raise EinopsError(f"Axis {elementary_axis} is not used in transform")
axis_name2known_length[elementary_axis] = _expected_axis_length
input_axes_known_unknown = []
# some shapes are inferred later - all information is prepared for faster inference
for composite_axis in left_composition:
known: Set[str] = {axis for axis in composite_axis if axis_name2known_length[axis] != _unknown_axis_length}
unknown: Set[str] = {axis for axis in composite_axis if axis_name2known_length[axis] == _unknown_axis_length}
if len(unknown) > 1:
raise EinopsError(f"Could not infer sizes for {unknown}")
assert len(unknown) + len(known) == len(composite_axis)
input_axes_known_unknown.append(
([axis_name2position[axis] for axis in known], [axis_name2position[axis] for axis in unknown])
)
axis_position_after_reduction: Dict[str, int] = {}
for axis_name in itertools.chain(*left_composition):
if axis_name in rght.identifiers:
axis_position_after_reduction[axis_name] = len(axis_position_after_reduction)
result_axes_grouping: List[List[int]] = [
[axis_name2position[axis] for axis in composite_axis] for i, composite_axis in enumerate(rght_composition)
]
ordered_axis_left = list(itertools.chain(*left_composition))
ordered_axis_rght = list(itertools.chain(*rght_composition))
reduced_axes = [axis for axis in ordered_axis_left if axis not in rght.identifiers]
order_after_transposition = [axis for axis in ordered_axis_rght if axis in left.identifiers] + reduced_axes
axes_permutation = [ordered_axis_left.index(axis) for axis in order_after_transposition]
added_axes = {
i: axis_name2position[axis_name]
for i, axis_name in enumerate(ordered_axis_rght)
if axis_name not in left.identifiers
}
first_reduced_axis = len(order_after_transposition) - len(reduced_axes)
return TransformRecipe(
elementary_axes_lengths=list(axis_name2known_length.values()),
axis_name2elementary_axis={axis: axis_name2position[axis] for axis in axes_names},
input_composition_known_unknown=input_axes_known_unknown,
axes_permutation=axes_permutation,
first_reduced_axis=first_reduced_axis,
added_axes=added_axes,
output_composite_axes=result_axes_grouping,
)
def _prepare_recipes_for_all_dims(
pattern: str, operation: Reduction, axes_names: Tuple[str, ...]
) -> Dict[int, TransformRecipe]:
"""
Internal function, used in layers.
Layer makes all recipe creation when it is initialized, thus to keep recipes simple we pre-compute for all dims
"""
left_str, rght_str = pattern.split("->")
left = ParsedExpression(left_str)
dims = [len(left.composition)]
if left.has_ellipsis:
dims = [len(left.composition) - 1 + ellipsis_dims for ellipsis_dims in range(8)]
return {ndim: _prepare_transformation_recipe(pattern, operation, axes_names, ndim=ndim) for ndim in dims}
@overload
def reduce(tensor: List[Tensor], pattern: str, reduction: Reduction, **axes_lengths: Size) -> Tensor: ...
@overload
def reduce(tensor: Tensor, pattern: str, reduction: Reduction, **axes_lengths: Size) -> Tensor: ...
def reduce(tensor: Union[Tensor, List[Tensor]], pattern: str, reduction: Reduction, **axes_lengths: Size) -> Tensor:
"""
einops.reduce combines rearrangement and reduction using reader-friendly notation.
Some examples:
```python
>>> x = np.random.randn(100, 32, 64)
# perform max-reduction on the first axis
# Axis t does not appear on RHS - thus we reduced over t
>>> y = reduce(x, 't b c -> b c', 'max')
# same as previous, but using verbose names for axes
>>> y = reduce(x, 'time batch channel -> batch channel', 'max')
# let's pretend now that x is a batch of images
# with 4 dims: batch=10, height=20, width=30, channel=40
>>> x = np.random.randn(10, 20, 30, 40)
# 2d max-pooling with kernel size = 2 * 2 for image processing
>>> y1 = reduce(x, 'b c (h1 h2) (w1 w2) -> b c h1 w1', 'max', h2=2, w2=2)
# same as previous, using anonymous axes,
# note: only reduced axes can be anonymous
>>> y1 = reduce(x, 'b c (h1 2) (w1 2) -> b c h1 w1', 'max')
# adaptive 2d max-pooling to 3 * 4 grid,
# each element is max of 10x10 tile in the original tensor.
>>> reduce(x, 'b c (h1 h2) (w1 w2) -> b c h1 w1', 'max', h1=3, w1=4).shape
(10, 20, 3, 4)
# Global average pooling
>>> reduce(x, 'b c h w -> b c', 'mean').shape
(10, 20)
# subtracting mean over batch for each channel;
# similar to x - np.mean(x, axis=(0, 2, 3), keepdims=True)
>>> y = x - reduce(x, 'b c h w -> 1 c 1 1', 'mean')
# Subtracting per-image mean for each channel
>>> y = x - reduce(x, 'b c h w -> b c 1 1', 'mean')
# same as previous, but using empty compositions
>>> y = x - reduce(x, 'b c h w -> b c () ()', 'mean')
```
Parameters:
tensor: tensor: tensor of any supported library (e.g. numpy.ndarray, tensorflow, pytorch).
list of tensors is also accepted, those should be of the same type and shape
pattern: string, reduction pattern
reduction: one of available reductions ('min', 'max', 'sum', 'mean', 'prod', 'any', 'all').
Alternatively, a callable f(tensor, reduced_axes) -> tensor can be provided.
This allows using various reductions like: np.max, np.nanmean, tf.reduce_logsumexp, torch.var, etc.
axes_lengths: any additional specifications for dimensions
Returns:
tensor of the same type as input
"""
try:
if isinstance(tensor, list):
if len(tensor) == 0:
raise TypeError("Rearrange/Reduce/Repeat can't be applied to an empty list")
backend = get_backend(tensor[0])
tensor = backend.stack_on_zeroth_dimension(tensor)
else:
backend = get_backend(tensor)
hashable_axes_lengths = tuple(axes_lengths.items())
shape = backend.shape(tensor)
recipe = _prepare_transformation_recipe(pattern, reduction, axes_names=tuple(axes_lengths), ndim=len(shape))
return _apply_recipe(
backend, recipe, cast(Tensor, tensor), reduction_type=reduction, axes_lengths=hashable_axes_lengths
)
except EinopsError as e:
message = f' Error while processing {reduction}-reduction pattern "{pattern}".'
if not isinstance(tensor, list):
message += f"\n Input tensor shape: {shape}. "
else:
message += "\n Input is list. "
message += f"Additional info: {axes_lengths}."
raise EinopsError(message + f"\n {e}") from None
@overload
def rearrange(tensor: List[Tensor], pattern: str, **axes_lengths: Size) -> Tensor: ...
@overload
def rearrange(tensor: Tensor, pattern: str, **axes_lengths: Size) -> Tensor: ...
def rearrange(tensor: Union[Tensor, List[Tensor]], pattern: str, **axes_lengths: Size) -> Tensor:
"""
einops.rearrange is a reader-friendly smart element reordering for multidimensional tensors.
This operation includes functionality of transpose (axes permutation), reshape (view), squeeze, unsqueeze,
stack, concatenate and other operations.
Examples:
```python
# suppose we have a set of 32 images in "h w c" format (height-width-channel)
>>> images = [np.random.randn(30, 40, 3) for _ in range(32)]
# stack along first (batch) axis, output is a single array
>>> rearrange(images, 'b h w c -> b h w c').shape
(32, 30, 40, 3)
# stacked and reordered axes to "b c h w" format
>>> rearrange(images, 'b h w c -> b c h w').shape
(32, 3, 30, 40)
# concatenate images along height (vertical axis), 960 = 32 * 30
>>> rearrange(images, 'b h w c -> (b h) w c').shape
(960, 40, 3)
# concatenated images along horizontal axis, 1280 = 32 * 40
>>> rearrange(images, 'b h w c -> h (b w) c').shape
(30, 1280, 3)
# flattened each image into a vector, 3600 = 30 * 40 * 3
>>> rearrange(images, 'b h w c -> b (c h w)').shape
(32, 3600)
# split each image into 4 smaller (top-left, top-right, bottom-left, bottom-right), 128 = 32 * 2 * 2
>>> rearrange(images, 'b (h1 h) (w1 w) c -> (b h1 w1) h w c', h1=2, w1=2).shape
(128, 15, 20, 3)
# space-to-depth operation
>>> rearrange(images, 'b (h h1) (w w1) c -> b h w (c h1 w1)', h1=2, w1=2).shape
(32, 15, 20, 12)
```
When composing axes, C-order enumeration used (consecutive elements have different last axis).
Find more examples in einops tutorial.
Parameters:
tensor: tensor of any supported library (e.g. numpy.ndarray, tensorflow, pytorch).
list of tensors is also accepted, those should be of the same type and shape
pattern: string, rearrangement pattern
axes_lengths: any additional specifications for dimensions
Returns:
tensor of the same type as input. If possible, a view to the original tensor is returned.
"""
return reduce(tensor, pattern, reduction="rearrange", **axes_lengths)
@overload
def repeat(tensor: List[Tensor], pattern: str, **axes_lengths: Size) -> Tensor: ...
@overload
def repeat(tensor: Tensor, pattern: str, **axes_lengths: Size) -> Tensor: ...
def repeat(tensor: Union[Tensor, List[Tensor]], pattern: str, **axes_lengths: Size) -> Tensor:
"""
einops.repeat allows reordering elements and repeating them in arbitrary combinations.
This operation includes functionality of repeat, tile, and broadcast functions.
Examples for repeat operation:
```python
# a grayscale image (of shape height x width)
>>> image = np.random.randn(30, 40)
# change it to RGB format by repeating in each channel
>>> repeat(image, 'h w -> h w c', c=3).shape
(30, 40, 3)
# repeat image 2 times along height (vertical axis)
>>> repeat(image, 'h w -> (repeat h) w', repeat=2).shape
(60, 40)
# repeat image 2 time along height and 3 times along width
>>> repeat(image, 'h w -> (h2 h) (w3 w)', h2=2, w3=3).shape
(60, 120)
# convert each pixel to a small square 2x2, i.e. upsample an image by 2x
>>> repeat(image, 'h w -> (h h2) (w w2)', h2=2, w2=2).shape
(60, 80)
# 'pixelate' an image first by downsampling by 2x, then upsampling
>>> downsampled = reduce(image, '(h h2) (w w2) -> h w', 'mean', h2=2, w2=2)
>>> repeat(downsampled, 'h w -> (h h2) (w w2)', h2=2, w2=2).shape
(30, 40)
```
When composing axes, C-order enumeration used (consecutive elements have different last axis).
Find more examples in einops tutorial.
Parameters:
tensor: tensor of any supported library (e.g. numpy.ndarray, tensorflow, pytorch).
list of tensors is also accepted, those should be of the same type and shape
pattern: string, rearrangement pattern
axes_lengths: any additional specifications for dimensions
Returns:
Tensor of the same type as input. If possible, a view to the original tensor is returned.
"""
return reduce(tensor, pattern, reduction="repeat", **axes_lengths)
def parse_shape(x: Tensor, pattern: str) -> dict:
"""
Parse a tensor shape to dictionary mapping axes names to their lengths.
```python
# Use underscore to skip the dimension in parsing.
>>> x = np.zeros([2, 3, 5, 7])
>>> parse_shape(x, 'batch _ h w')
{'batch': 2, 'h': 5, 'w': 7}
# `parse_shape` output can be used to specify axes_lengths for other operations:
>>> y = np.zeros([700])
>>> rearrange(y, '(b c h w) -> b c h w', **parse_shape(x, 'b _ h w')).shape
(2, 10, 5, 7)
```
For symbolic frameworks may return symbols, not integers.
Parameters:
x: tensor of any supported framework
pattern: str, space separated names for axes, underscore means skip axis
Returns:
dict, maps axes names to their lengths
"""
exp = ParsedExpression(pattern, allow_underscore=True)
shape = get_backend(x).shape(x)
if exp.has_composed_axes():
raise RuntimeError(f"Can't parse shape with composite axes: {pattern} {shape}")
if len(shape) != len(exp.composition):
if exp.has_ellipsis:
if len(shape) < len(exp.composition) - 1:
raise RuntimeError(f"Can't parse shape with this number of dimensions: {pattern} {shape}")
else:
raise RuntimeError(f"Can't parse shape with different number of dimensions: {pattern} {shape}")
if exp.has_ellipsis:
ellipsis_idx = exp.composition.index(_ellipsis)
composition = (
exp.composition[:ellipsis_idx]
+ ["_"] * (len(shape) - len(exp.composition) + 1)
+ exp.composition[ellipsis_idx + 1 :]
)
else:
composition = exp.composition
result = {}
for axes, axis_length in zip(composition, shape): # type: ignore
# axes either [], or [AnonymousAxis] or ['axis_name']
if len(axes) == 0:
if axis_length != 1:
raise RuntimeError(f"Length of axis is not 1: {pattern} {shape}")
else:
[axis] = axes
if isinstance(axis, str):
if axis != "_":
result[axis] = axis_length
else:
if axis.value != axis_length:
raise RuntimeError(f"Length of anonymous axis does not match: {pattern} {shape}")
return result
# _enumerate_directions is not exposed in the public API
def _enumerate_directions(x):
"""
For an n-dimensional tensor, returns tensors to enumerate each axis.
```python
x = np.zeros([2, 3, 4]) # or any other tensor
i, j, k = _enumerate_directions(x)
result = i + 2*j + 3*k
```
`result[i, j, k] = i + 2j + 3k`, and also has the same shape as result
Works very similarly to numpy.ogrid (open indexing grid)
"""
backend = get_backend(x)
shape = backend.shape(x)
result = []
for axis_id, axis_length in enumerate(shape):
shape = [1] * len(shape)
shape[axis_id] = axis_length
result.append(backend.reshape(backend.arange(0, axis_length), shape))
return result
# to avoid importing numpy
np_ndarray = Any
def asnumpy(tensor: Tensor) -> np_ndarray:
"""
Convert a tensor of an imperative framework (i.e. numpy/cupy/torch/jax/etc.) to `numpy.ndarray`
Parameters:
tensor: tensor of any known imperative framework
Returns:
`numpy.ndarray`, converted to numpy
"""
return get_backend(tensor).to_numpy(tensor)
def _validate_einsum_axis_name(axis_name):
if len(axis_name) == 0:
raise NotImplementedError("Singleton () axes are not yet supported in einsum.")
if len(axis_name) > 1:
raise NotImplementedError("Shape rearrangement is not yet supported in einsum.")
axis_name = axis_name[0]
if isinstance(axis_name, AnonymousAxis):
raise NotImplementedError("Anonymous axes are not yet supported in einsum.")
if len(axis_name) == 0:
raise RuntimeError("Encountered empty axis name in einsum.")
if not isinstance(axis_name, str):
raise RuntimeError("Axis name in einsum must be a string.")
@functools.lru_cache(256)
def _compactify_pattern_for_einsum(pattern: str) -> str:
if "->" not in pattern:
# numpy allows this, so make sure users
# don't accidentally do something like this.
raise ValueError("Einsum pattern must contain '->'.")
lefts_str, right_str = pattern.split("->")
lefts = [ParsedExpression(left, allow_underscore=True, allow_duplicates=True) for left in lefts_str.split(",")]
right = ParsedExpression(right_str, allow_underscore=True)
# Start from 'a' and go up to 'Z'
output_axis_names = string.ascii_letters
i = 0
axis_name_mapping = {}
left_patterns = []
for left in lefts:
left_pattern = ""
for raw_axis_name in left.composition:
if raw_axis_name == _ellipsis:
left_pattern += "..."
continue
_validate_einsum_axis_name(raw_axis_name)
axis_name = raw_axis_name[0]
if axis_name not in axis_name_mapping:
if i >= len(output_axis_names):
raise RuntimeError("Too many axes in einsum.")
axis_name_mapping[axis_name] = output_axis_names[i]
i += 1
left_pattern += axis_name_mapping[axis_name]
left_patterns.append(left_pattern)
compact_pattern = ",".join(left_patterns) + "->"
for raw_axis_name in right.composition:
if raw_axis_name == _ellipsis:
compact_pattern += "..."
continue
_validate_einsum_axis_name(raw_axis_name)
axis_name = raw_axis_name[0]
if axis_name not in axis_name_mapping:
raise EinopsError(f"Unknown axis {axis_name} on right side of einsum {pattern}.")
compact_pattern += axis_name_mapping[axis_name]
return compact_pattern
@typing.overload
def einsum(tensor: Tensor, pattern: str, /) -> Tensor: ...
@typing.overload
def einsum(tensor1: Tensor, tensor2: Tensor, pattern: str, /) -> Tensor: ...
@typing.overload
def einsum(tensor1: Tensor, tensor2: Tensor, tensor3: Tensor, pattern: str, /) -> Tensor: ...
@typing.overload
def einsum(tensor1: Tensor, tensor2: Tensor, tensor3: Tensor, tensor4: Tensor, pattern: str, /) -> Tensor: ...
def einsum(*tensors_and_pattern: Union[Tensor, str]) -> Tensor:
r"""
einops.einsum calls einsum operations with einops-style named
axes indexing, computing tensor products with an arbitrary
number of tensors. Unlike typical einsum syntax, here you must
pass tensors first, and then the pattern.
Also, note that rearrange operations such as `"(batch chan) out"`,
or singleton axes `()`, are not currently supported.
Examples:
For a given pattern such as:
```python
>>> x, y, z = np.random.randn(3, 20, 20, 20)
>>> output = einsum(x, y, z, "a b c, c b d, a g k -> a b k")
```
the following formula is computed:
```tex
output[a, b, k] = \sum_{c, d, g} x[a, b, c] * y[c, b, d] * z[a, g, k]
```
where the summation over `c`, `d`, and `g` is performed
because those axes names do not appear on the right-hand side.
Let's see some additional examples:
```python
# Filter a set of images:
>>> batched_images = np.random.randn(128, 16, 16)
>>> filters = np.random.randn(16, 16, 30)
>>> result = einsum(batched_images, filters,
... "batch h w, h w channel -> batch channel")
>>> result.shape
(128, 30)
# Matrix multiplication, with an unknown input shape:
>>> batch_shape = (50, 30)
>>> data = np.random.randn(*batch_shape, 20)
>>> weights = np.random.randn(10, 20)
>>> result = einsum(weights, data,
... "out_dim in_dim, ... in_dim -> ... out_dim")
>>> result.shape
(50, 30, 10)
# Matrix trace on a single tensor:
>>> matrix = np.random.randn(10, 10)
>>> result = einsum(matrix, "i i ->")
>>> result.shape
()
```
Parameters:
tensors_and_pattern:
tensors: tensors of any supported library (numpy, tensorflow, pytorch, jax).
pattern: string, einsum pattern, with commas
separating specifications for each tensor.
pattern should be provided after all tensors.
Returns:
Tensor of the same type as input, after processing with einsum.
"""
if len(tensors_and_pattern) <= 1:
raise ValueError(
"`einops.einsum` takes at minimum two arguments: the tensors (at least one), followed by the pattern."
)
pattern = tensors_and_pattern[-1]
if not isinstance(pattern, str):
raise ValueError(
"The last argument passed to `einops.einsum` must be a string, representing the einsum pattern."
)
tensors = tensors_and_pattern[:-1]
pattern = _compactify_pattern_for_einsum(pattern)
return get_backend(tensors[0]).einsum(pattern, *tensors)
@@ -0,0 +1,5 @@
"""
This file contained some thoughts on indexing.
These ideas were developed further in eindex (separate package).
"""
@@ -0,0 +1,105 @@
__author__ = "Alex Rogozhnikov"
from typing import Any, Dict
from einops import EinopsError
from einops.einops import TransformRecipe, _apply_recipe, _prepare_recipes_for_all_dims, get_backend
class RearrangeMixin:
"""
Rearrange layer behaves identically to einops.rearrange operation.
:param pattern: str, rearrangement pattern
:param axes_lengths: any additional specification of dimensions
See einops.rearrange for source_examples.
"""
def __init__(self, pattern: str, **axes_lengths: Any) -> None:
super().__init__()
self.pattern = pattern
self.axes_lengths = axes_lengths
# self._recipe = self.recipe() # checking parameters
self._multirecipe = self.multirecipe()
self._axes_lengths = tuple(self.axes_lengths.items())
def __repr__(self) -> str:
params = repr(self.pattern)
for axis, length in self.axes_lengths.items():
params += f", {axis}={length}"
return f"{self.__class__.__name__}({params})"
def multirecipe(self) -> Dict[int, TransformRecipe]:
try:
return _prepare_recipes_for_all_dims(
self.pattern, operation="rearrange", axes_names=tuple(self.axes_lengths)
)
except EinopsError as e:
raise EinopsError(f" Error while preparing {self!r}\n {e}") from None
def _apply_recipe(self, x):
backend = get_backend(x)
return _apply_recipe(
backend=backend,
recipe=self._multirecipe[len(x.shape)],
tensor=x,
reduction_type="rearrange",
axes_lengths=self._axes_lengths,
)
def __getstate__(self):
return {"pattern": self.pattern, "axes_lengths": self.axes_lengths}
def __setstate__(self, state):
self.__init__(pattern=state["pattern"], **state["axes_lengths"])
class ReduceMixin:
"""
Reduce layer behaves identically to einops.reduce operation.
:param pattern: str, rearrangement pattern
:param reduction: one of available reductions ('min', 'max', 'sum', 'mean', 'prod'), case-sensitive
:param axes_lengths: any additional specification of dimensions
See einops.reduce for source_examples.
"""
def __init__(self, pattern: str, reduction: str, **axes_lengths: Any):
super().__init__()
self.pattern = pattern
self.reduction = reduction
self.axes_lengths = axes_lengths
self._multirecipe = self.multirecipe()
self._axes_lengths = tuple(self.axes_lengths.items())
def __repr__(self):
params = f"{self.pattern!r}, {self.reduction!r}"
for axis, length in self.axes_lengths.items():
params += f", {axis}={length}"
return f"{self.__class__.__name__}({params})"
def multirecipe(self) -> Dict[int, TransformRecipe]:
try:
return _prepare_recipes_for_all_dims(
self.pattern, operation=self.reduction, axes_names=tuple(self.axes_lengths)
)
except EinopsError as e:
raise EinopsError(f" Error while preparing {self!r}\n {e}") from None
def _apply_recipe(self, x):
backend = get_backend(x)
return _apply_recipe(
backend=backend,
recipe=self._multirecipe[len(x.shape)],
tensor=x,
reduction_type=self.reduction,
axes_lengths=self._axes_lengths,
)
def __getstate__(self):
return {"pattern": self.pattern, "reduction": self.reduction, "axes_lengths": self.axes_lengths}
def __setstate__(self, state):
self.__init__(pattern=state["pattern"], reduction=state["reduction"], **state["axes_lengths"])
@@ -0,0 +1,227 @@
import string
import warnings
from typing import Any, Dict, List, Optional
from einops import EinopsError
from einops.einops import _product
from einops.parsing import ParsedExpression, _ellipsis
def _report_axes(axes: set, report_message: str):
if len(axes) > 0:
raise EinopsError(report_message.format(axes))
class _EinmixMixin:
def __init__(self, pattern: str, weight_shape: str, bias_shape: Optional[str] = None, **axes_lengths: Any):
"""
EinMix - Einstein summation with automated tensor management and axis packing/unpacking.
EinMix is a combination of einops and MLP, see tutorial:
https://github.com/arogozhnikov/einops/blob/main/docs/3-einmix-layer.ipynb
Imagine taking einsum with two arguments, one of each input, and one - tensor with weights
>>> einsum('time batch channel_in, channel_in channel_out -> time batch channel_out', input, weight)
This layer manages weights for you, syntax highlights a special role of weight matrix
>>> EinMix('time batch channel_in -> time batch channel_out', weight_shape='channel_in channel_out')
But otherwise it is the same einsum under the hood. Plus einops-rearrange.
Simple linear layer with a bias term (you have one like that in your framework)
>>> EinMix('t b cin -> t b cout', weight_shape='cin cout', bias_shape='cout', cin=10, cout=20)
There is no restriction to mix the last axis. Let's mix along height
>>> EinMix('h w c-> hout w c', weight_shape='h hout', bias_shape='hout', h=32, hout=32)
Example of channel-wise multiplication (like one used in normalizations)
>>> EinMix('t b c -> t b c', weight_shape='c', c=128)
Multi-head linear layer (each head is own linear layer):
>>> EinMix('t b (head cin) -> t b (head cout)', weight_shape='head cin cout', ...)
... and yes, you need to specify all dimensions of weight shape/bias shape in parameters.
Use cases:
- when channel dimension is not last, use EinMix, not transposition
- patch/segment embeddings
- when need only within-group connections to reduce number of weights and computations
- next-gen MLPs (follow tutorial link above to learn more!)
- in general, any time you want to combine linear layer and einops.rearrange
Uniform He initialization is applied to weight tensor.
This accounts for the number of elements mixed and produced.
Parameters
:param pattern: transformation pattern, left side - dimensions of input, right side - dimensions of output
:param weight_shape: axes of weight. A tensor of this shape is created, stored, and optimized in a layer
If bias_shape is not specified, bias is not created.
:param bias_shape: axes of bias added to output. Weights of this shape are created and stored. If `None` (the default), no bias is added.
:param axes_lengths: dimensions of weight tensor
"""
super().__init__()
self.pattern = pattern
self.weight_shape = weight_shape
self.bias_shape = bias_shape
self.axes_lengths = axes_lengths
self.initialize_einmix(
pattern=pattern, weight_shape=weight_shape, bias_shape=bias_shape, axes_lengths=axes_lengths
)
def initialize_einmix(self, pattern: str, weight_shape: str, bias_shape: Optional[str], axes_lengths: dict):
left_pattern, right_pattern = pattern.split("->")
left = ParsedExpression(left_pattern)
right = ParsedExpression(right_pattern)
weight = ParsedExpression(weight_shape)
_report_axes(
set.difference(right.identifiers, {*left.identifiers, *weight.identifiers}),
"Unrecognized identifiers on the right side of EinMix {}",
)
if weight.has_ellipsis:
raise EinopsError("Ellipsis is not supported in weight, as its shape should be fully specified")
if left.has_ellipsis or right.has_ellipsis:
if not (left.has_ellipsis and right.has_ellipsis):
raise EinopsError(f"Ellipsis in EinMix should be on both sides, {pattern}")
if left.has_ellipsis_parenthesized:
raise EinopsError(f"Ellipsis on left side can't be in parenthesis, got {pattern}")
if any(x.has_non_unitary_anonymous_axes for x in [left, right, weight]):
raise EinopsError("Anonymous axes (numbers) are not allowed in EinMix")
if "(" in weight_shape or ")" in weight_shape:
raise EinopsError(f"Parenthesis is not allowed in weight shape: {weight_shape}")
pre_reshape_pattern = None
pre_reshape_lengths = None
post_reshape_pattern = None
if any(len(group) != 1 for group in left.composition):
names: List[str] = []
for group in left.composition:
names += group
names = [name if name != _ellipsis else "..." for name in names]
composition = " ".join(names)
pre_reshape_pattern = f"{left_pattern}-> {composition}"
pre_reshape_lengths = {name: length for name, length in axes_lengths.items() if name in names}
if any(len(group) != 1 for group in right.composition) or right.has_ellipsis_parenthesized:
names = []
for group in right.composition:
names += group
names = [name if name != _ellipsis else "..." for name in names]
composition = " ".join(names)
post_reshape_pattern = f"{composition} ->{right_pattern}"
self._create_rearrange_layers(pre_reshape_pattern, pre_reshape_lengths, post_reshape_pattern, {})
for axis in weight.identifiers:
if axis not in axes_lengths:
raise EinopsError(f"Dimension {axis} of weight should be specified")
_report_axes(
set.difference(set(axes_lengths), {*left.identifiers, *weight.identifiers}),
"Axes {} are not used in pattern",
)
_report_axes(
set.difference(weight.identifiers, {*left.identifiers, *right.identifiers}), "Weight axes {} are redundant"
)
if len(weight.identifiers) == 0:
warnings.warn("EinMix: weight has no dimensions (means multiplication by a number)", stacklevel=2)
_weight_shape = [axes_lengths[axis] for (axis,) in weight.composition]
# single output element is a combination of fan_in input elements
_fan_in = _product([axes_lengths[axis] for (axis,) in weight.composition if axis not in right.identifiers])
if bias_shape is not None:
# maybe I should put ellipsis in the beginning for simplicity?
if not isinstance(bias_shape, str):
raise EinopsError("bias shape should be string specifying which axes bias depends on")
bias = ParsedExpression(bias_shape)
_report_axes(
set.difference(bias.identifiers, right.identifiers),
"Bias axes {} not present in output",
)
_report_axes(
set.difference(bias.identifiers, set(axes_lengths)),
"Sizes not provided for bias axes {}",
)
_bias_shape = []
used_non_trivial_size = False
for axes in right.composition:
if axes == _ellipsis:
if used_non_trivial_size:
raise EinopsError("all bias dimensions should go after ellipsis in the output")
else:
# handles ellipsis correctly
for axis in axes:
if axis == _ellipsis:
if used_non_trivial_size:
raise EinopsError("all bias dimensions should go after ellipsis in the output")
elif axis in bias.identifiers:
_bias_shape.append(axes_lengths[axis])
used_non_trivial_size = True
else:
_bias_shape.append(1)
else:
_bias_shape = None
weight_bound = (3 / _fan_in) ** 0.5
bias_bound = (1 / _fan_in) ** 0.5
self._create_parameters(_weight_shape, weight_bound, _bias_shape, bias_bound)
# rewrite einsum expression with single-letter latin identifiers so that
# expression will be understood by any framework
mapped_identifiers = {*left.identifiers, *right.identifiers, *weight.identifiers}
if _ellipsis in mapped_identifiers:
mapped_identifiers.remove(_ellipsis)
mapped_identifiers = sorted(mapped_identifiers)
mapping2letters = {k: letter for letter, k in zip(string.ascii_lowercase, mapped_identifiers)}
mapping2letters[_ellipsis] = "..." # preserve ellipsis
def write_flat_remapped(axes: ParsedExpression):
result = []
for composed_axis in axes.composition:
if isinstance(composed_axis, list):
result.extend([mapping2letters[axis] for axis in composed_axis])
else:
assert composed_axis == _ellipsis
result.append("...")
return "".join(result)
self.einsum_pattern: str = (
f"{write_flat_remapped(left)},{write_flat_remapped(weight)}->{write_flat_remapped(right)}"
)
def _create_rearrange_layers(
self,
pre_reshape_pattern: Optional[str],
pre_reshape_lengths: Optional[Dict],
post_reshape_pattern: Optional[str],
post_reshape_lengths: Optional[Dict],
):
raise NotImplementedError("Should be defined in framework implementations")
def _create_parameters(self, weight_shape, weight_bound, bias_shape, bias_bound):
"""Shape and implementations"""
raise NotImplementedError("Should be defined in framework implementations")
def __repr__(self):
params = repr(self.pattern)
params += f", '{self.weight_shape}'"
if self.bias_shape is not None:
params += f", '{self.bias_shape}'"
for axis, length in self.axes_lengths.items():
params += f", {axis}={length}"
return f"{self.__class__.__name__}({params})"
class _EinmixDebugger(_EinmixMixin):
"""Used only to test mixin"""
def _create_rearrange_layers(
self,
pre_reshape_pattern: Optional[str],
pre_reshape_lengths: Optional[Dict],
post_reshape_pattern: Optional[str],
post_reshape_lengths: Optional[Dict],
):
self.pre_reshape_pattern = pre_reshape_pattern
self.pre_reshape_lengths = pre_reshape_lengths
self.post_reshape_pattern = post_reshape_pattern
self.post_reshape_lengths = post_reshape_lengths
def _create_parameters(self, weight_shape, weight_bound, bias_shape, bias_bound):
self.saved_weight_shape = weight_shape
self.saved_bias_shape = bias_shape
@@ -0,0 +1,82 @@
from dataclasses import field
from typing import Dict, Optional, cast
import flax.linen as nn
import jax
import jax.numpy as jnp
from . import RearrangeMixin, ReduceMixin
from ._einmix import _EinmixMixin
__author__ = "Alex Rogozhnikov"
class Reduce(nn.Module):
pattern: str
reduction: str
sizes: dict = field(default_factory=dict)
def setup(self):
self.reducer = ReduceMixin(self.pattern, self.reduction, **self.sizes)
def __call__(self, input):
return self.reducer._apply_recipe(input)
class Rearrange(nn.Module):
pattern: str
sizes: dict = field(default_factory=dict)
def setup(self):
self.rearranger = RearrangeMixin(self.pattern, **self.sizes)
def __call__(self, input):
return self.rearranger._apply_recipe(input)
class EinMix(nn.Module, _EinmixMixin):
pattern: str
weight_shape: str
bias_shape: Optional[str] = None
sizes: dict = field(default_factory=dict)
def setup(self):
self.initialize_einmix(
pattern=self.pattern,
weight_shape=self.weight_shape,
bias_shape=self.bias_shape,
axes_lengths=self.sizes,
)
def _create_parameters(self, weight_shape, weight_bound, bias_shape, bias_bound):
self.weight = self.param("weight", jax.nn.initializers.uniform(weight_bound), weight_shape)
if bias_shape is not None:
self.bias = self.param("bias", jax.nn.initializers.uniform(bias_bound), bias_shape)
else:
self.bias = None
def _create_rearrange_layers(
self,
pre_reshape_pattern: Optional[str],
pre_reshape_lengths: Optional[Dict],
post_reshape_pattern: Optional[str],
post_reshape_lengths: Optional[Dict],
):
self.pre_rearrange = None
if pre_reshape_pattern is not None:
self.pre_rearrange = Rearrange(pre_reshape_pattern, sizes=cast(dict, pre_reshape_lengths))
self.post_rearrange = None
if post_reshape_pattern is not None:
self.post_rearrange = Rearrange(post_reshape_pattern, sizes=cast(dict, post_reshape_lengths))
def __call__(self, input):
if self.pre_rearrange is not None:
input = self.pre_rearrange(input)
result = jnp.einsum(self.einsum_pattern, input, self.weight)
if self.bias is not None:
result += self.bias
if self.post_rearrange is not None:
result = self.post_rearrange(result)
return result
@@ -0,0 +1,9 @@
__author__ = "Alex Rogozhnikov"
from einops.layers.tensorflow import EinMix, Rearrange, Reduce
keras_custom_objects = {
Rearrange.__name__: Rearrange,
Reduce.__name__: Reduce,
EinMix.__name__: EinMix,
}
@@ -0,0 +1,54 @@
from typing import Dict, Optional, cast
import oneflow as flow
from . import RearrangeMixin, ReduceMixin
from ._einmix import _EinmixMixin
__author__ = "Tianhe Ren & Depeng Liang"
class Rearrange(RearrangeMixin, flow.nn.Module):
def forward(self, input):
return self._apply_recipe(input)
class Reduce(ReduceMixin, flow.nn.Module):
def forward(self, input):
return self._apply_recipe(input)
class EinMix(_EinmixMixin, flow.nn.Module):
def _create_parameters(self, weight_shape, weight_bound, bias_shape, bias_bound):
self.weight = flow.nn.Parameter(
flow.zeros(weight_shape).uniform_(-weight_bound, weight_bound), requires_grad=True
)
if bias_shape is not None:
self.bias = flow.nn.Parameter(flow.zeros(bias_shape).uniform_(-bias_bound, bias_bound), requires_grad=True)
else:
self.bias = None
def _create_rearrange_layers(
self,
pre_reshape_pattern: Optional[str],
pre_reshape_lengths: Optional[Dict],
post_reshape_pattern: Optional[str],
post_reshape_lengths: Optional[Dict],
):
self.pre_rearrange = None
if pre_reshape_pattern is not None:
self.pre_rearrange = Rearrange(pre_reshape_pattern, **cast(dict, pre_reshape_lengths))
self.post_rearrange = None
if post_reshape_pattern is not None:
self.post_rearrange = Rearrange(post_reshape_pattern, **cast(dict, post_reshape_lengths))
def forward(self, input):
if self.pre_rearrange is not None:
input = self.pre_rearrange(input)
result = flow.einsum(self.einsum_pattern, input, self.weight)
if self.bias is not None:
result += self.bias
if self.post_rearrange is not None:
result = self.post_rearrange(result)
return result
@@ -0,0 +1,58 @@
from typing import Dict, Optional, cast
import paddle
from . import RearrangeMixin, ReduceMixin
from ._einmix import _EinmixMixin
__author__ = "PaddlePaddle"
class Rearrange(RearrangeMixin, paddle.nn.Layer):
def forward(self, input):
return self._apply_recipe(input)
class Reduce(ReduceMixin, paddle.nn.Layer):
def forward(self, input):
return self._apply_recipe(input)
class EinMix(_EinmixMixin, paddle.nn.Layer):
def _create_parameters(self, weight_shape, weight_bound, bias_shape, bias_bound):
self.weight = self.create_parameter(
weight_shape, default_initializer=paddle.nn.initializer.Uniform(-weight_bound, weight_bound)
)
if bias_shape is not None:
self.bias = self.create_parameter(
bias_shape, default_initializer=paddle.nn.initializer.Uniform(-bias_bound, bias_bound)
)
else:
self.bias = None
def _create_rearrange_layers(
self,
pre_reshape_pattern: Optional[str],
pre_reshape_lengths: Optional[Dict],
post_reshape_pattern: Optional[str],
post_reshape_lengths: Optional[Dict],
):
self.pre_rearrange = None
if pre_reshape_pattern is not None:
self.pre_rearrange = Rearrange(pre_reshape_pattern, **cast(dict, pre_reshape_lengths))
self.post_rearrange = None
if post_reshape_pattern is not None:
self.post_rearrange = Rearrange(post_reshape_pattern, **cast(dict, post_reshape_lengths))
def forward(self, input):
if self.pre_rearrange is not None:
input = self.pre_rearrange(input)
result = paddle.einsum(self.einsum_pattern, input, self.weight)
if self.bias is not None:
result += self.bias
if self.post_rearrange is not None:
result = self.post_rearrange(result)
return result
@@ -0,0 +1,101 @@
"""
Comment about tensorflow layers:
unfortunately instructions on creation of TF layers change constantly,
and changed way too many times at this point to remember what-compatible-where.
Layers in einops==0.7.0 (and several prior versions)
are compatible with TF 2.13
Layers in einops==0.8.0 were re-implemented
according to official instructions for TF 2.16
"""
from typing import Dict, Optional, cast
import tensorflow as tf
from tensorflow.keras.layers import Layer
from . import RearrangeMixin, ReduceMixin
from ._einmix import _EinmixMixin
__author__ = "Alex Rogozhnikov"
class Rearrange(RearrangeMixin, Layer):
def build(self, input_shape):
pass # layer does not have any parameters to be initialized
def call(self, inputs):
return self._apply_recipe(inputs)
def get_config(self):
return {"pattern": self.pattern, **self.axes_lengths}
class Reduce(ReduceMixin, Layer):
def build(self, input_shape):
pass # layer does not have any parameters to be initialized
def call(self, inputs):
return self._apply_recipe(inputs)
def get_config(self):
return {"pattern": self.pattern, "reduction": self.reduction, **self.axes_lengths}
class EinMix(_EinmixMixin, Layer):
def _create_parameters(self, weight_shape, weight_bound, bias_shape, bias_bound):
# this method is called in __init__,
# but we postpone actual creation to build(), as TF instruction suggests
self._params = [weight_shape, weight_bound, bias_shape, bias_bound]
def _create_rearrange_layers(
self,
pre_reshape_pattern: Optional[str],
pre_reshape_lengths: Optional[Dict],
post_reshape_pattern: Optional[str],
post_reshape_lengths: Optional[Dict],
):
self.pre_rearrange = None
if pre_reshape_pattern is not None:
self.pre_rearrange = Rearrange(pre_reshape_pattern, **cast(dict, pre_reshape_lengths))
self.post_rearrange = None
if post_reshape_pattern is not None:
self.post_rearrange = Rearrange(post_reshape_pattern, **cast(dict, post_reshape_lengths))
def build(self, input_shape):
[weight_shape, weight_bound, bias_shape, bias_bound] = self._params
self.weight = self.add_weight(
shape=weight_shape,
initializer=tf.random_uniform_initializer(-weight_bound, weight_bound),
trainable=True,
)
if bias_shape is not None:
self.bias = self.add_weight(
shape=bias_shape,
initializer=tf.random_uniform_initializer(-bias_bound, bias_bound),
trainable=True,
)
else:
self.bias = None
def call(self, inputs):
if self.pre_rearrange is not None:
inputs = self.pre_rearrange(inputs)
result = tf.einsum(self.einsum_pattern, inputs, self.weight)
if self.bias is not None:
result = result + self.bias
if self.post_rearrange is not None:
result = self.post_rearrange(result)
return result
def get_config(self):
return {
"pattern": self.pattern,
"weight_shape": self.weight_shape,
"bias_shape": self.bias_shape,
**self.axes_lengths,
}
@@ -0,0 +1,68 @@
from typing import Dict, Optional, cast
import torch
from einops._torch_specific import apply_for_scriptable_torch
from . import RearrangeMixin, ReduceMixin
from ._einmix import _EinmixMixin
__author__ = "Alex Rogozhnikov"
class Rearrange(RearrangeMixin, torch.nn.Module):
def forward(self, input):
recipe = self._multirecipe[input.ndim]
return apply_for_scriptable_torch(recipe, input, reduction_type="rearrange", axes_dims=self._axes_lengths)
def _apply_recipe(self, x):
# overriding parent method to prevent it's scripting
pass
class Reduce(ReduceMixin, torch.nn.Module):
def forward(self, input):
recipe = self._multirecipe[input.ndim]
return apply_for_scriptable_torch(recipe, input, reduction_type=self.reduction, axes_dims=self._axes_lengths)
def _apply_recipe(self, x):
# overriding parent method to prevent it's scripting
pass
class EinMix(_EinmixMixin, torch.nn.Module):
def _create_parameters(self, weight_shape, weight_bound, bias_shape, bias_bound):
self.weight = torch.nn.Parameter(
torch.zeros(weight_shape).uniform_(-weight_bound, weight_bound), requires_grad=True
)
if bias_shape is not None:
self.bias = torch.nn.Parameter(
torch.zeros(bias_shape).uniform_(-bias_bound, bias_bound), requires_grad=True
)
else:
self.bias = None
def _create_rearrange_layers(
self,
pre_reshape_pattern: Optional[str],
pre_reshape_lengths: Optional[Dict],
post_reshape_pattern: Optional[str],
post_reshape_lengths: Optional[Dict],
):
self.pre_rearrange = None
if pre_reshape_pattern is not None:
self.pre_rearrange = Rearrange(pre_reshape_pattern, **cast(dict, pre_reshape_lengths))
self.post_rearrange = None
if post_reshape_pattern is not None:
self.post_rearrange = Rearrange(post_reshape_pattern, **cast(dict, post_reshape_lengths))
def forward(self, input):
if self.pre_rearrange is not None:
input = self.pre_rearrange(input)
result = torch.einsum(self.einsum_pattern, input, self.weight)
if self.bias is not None:
result += self.bias
if self.post_rearrange is not None:
result = self.post_rearrange(result)
return result
@@ -0,0 +1,189 @@
from functools import lru_cache
from typing import List, Sequence, Tuple, TypeVar, Union
from einops import EinopsError
from einops._backends import get_backend
from einops.parsing import ParsedExpression
Tensor = TypeVar("Tensor")
Shape = Union[Tuple[int, ...], List[int]]
@lru_cache(maxsize=128)
def analyze_pattern(pattern: str, opname: str) -> Tuple[int, int, int]:
# Maybe some validation of identifiers?
axes = pattern.split()
axes_set = set(axes)
if len(axes) != len(axes_set):
raise EinopsError(f'Duplicates in axes names in {opname}(..., "{pattern}")')
if "*" not in axes_set:
raise EinopsError(f'No *-axis in {opname}(..., "{pattern}")')
for axis in axes:
if axis != "*":
is_valid, reason = ParsedExpression.check_axis_name_return_reason(axis)
if not is_valid:
raise EinopsError(f'Invalid axis name {axis} in {opname}(..., "{pattern}")')
n_axes_before = axes.index("*")
n_axes_after = len(axes) - n_axes_before - 1
min_axes = n_axes_before + n_axes_after
return n_axes_before, n_axes_after, min_axes
def pack(tensors: Sequence[Tensor], pattern: str) -> Tuple[Tensor, List[Shape]]:
"""
Packs several tensors into one.
See einops tutorial for introduction into packing (and how it replaces stack and concatenation).
Parameters:
tensors: tensors to be packed, can be of different dimensionality
pattern: pattern that is shared for all inputs and output, e.g. "i j * k" or "batch seq *"
Returns:
(packed_tensor, packed_shapes aka PS)
Example:
```python
>>> from numpy import zeros as Z
>>> inputs = [Z([2, 3, 5]), Z([2, 3, 7, 5]), Z([2, 3, 7, 9, 5])]
>>> packed, ps = pack(inputs, 'i j * k')
>>> packed.shape, ps
((2, 3, 71, 5), [(), (7,), (7, 9)])
```
In this example, axes were matched to: i=2, j=3, k=5 based on order (first, second, and last).
All other axes were 'packed' and concatenated.
PS (packed shapes) contains information about axes that were matched to '*' in every input.
Resulting tensor has as many elements as all inputs in total.
Packing can be reversed with unpack, which additionally needs PS (packed shapes) to reconstruct order.
```python
>>> inputs_unpacked = unpack(packed, ps, 'i j * k')
>>> [x.shape for x in inputs_unpacked]
[(2, 3, 5), (2, 3, 7, 5), (2, 3, 7, 9, 5)]
```
Read the tutorial for introduction and application scenarios.
"""
n_axes_before, n_axes_after, min_axes = analyze_pattern(pattern, "pack")
# packing zero tensors is illegal
backend = get_backend(tensors[0])
reshaped_tensors: List[Tensor] = []
packed_shapes: List[Shape] = []
for i, tensor in enumerate(tensors):
shape = backend.shape(tensor)
if len(shape) < min_axes:
raise EinopsError(
f"packed tensor #{i} (enumeration starts with 0) has shape {shape}, "
f"while pattern {pattern} assumes at least {min_axes} axes"
)
axis_after_packed_axes = len(shape) - n_axes_after
packed_shapes.append(shape[n_axes_before:axis_after_packed_axes])
reshaped_tensors.append(backend.reshape(tensor, (*shape[:n_axes_before], -1, *shape[axis_after_packed_axes:])))
return backend.concat(reshaped_tensors, axis=n_axes_before), packed_shapes
def prod(x: Shape) -> int:
result = 1
for i in x:
result *= i
return result
def unpack(tensor: Tensor, packed_shapes: List[Shape], pattern: str) -> List[Tensor]:
"""
Unpacks a single tensor into several by splitting over a selected axes.
See einops tutorial for introduction into packing (and how it replaces stack and concatenation).
Parameters:
tensor: tensor to be unpacked
packed_shapes: packed_shapes (aka PS) is a list of shapes that take place of '*' in each output.
output will contain a single tensor for every provided shape
pattern: pattern that is shared for input and all outputs, e.g. "i j * k" or "batch seq *",
where * designates an axis to be unpacked
Returns:
list of tensors
If framework supports views, results are views to the original tensor.
Example:
```python
>>> from numpy import zeros as Z
>>> inputs = [Z([2, 3, 5]), Z([2, 3, 7, 5]), Z([2, 3, 7, 9, 5])]
>>> packed, ps = pack(inputs, 'i j * k')
>>> packed.shape, ps
((2, 3, 71, 5), [(), (7,), (7, 9)])
```
In this example, axes were matched to: i=2, j=3, k=5 based on order (first, second, and last).
All other axes were 'packed' and concatenated.
PS (packed shapes) contains information about axes that were matched to '*' in every input.
Resulting tensor has as many elements as all inputs in total.
Packing can be reversed with unpack, which additionally needs PS (packed shapes) to reconstruct order.
```python
>>> inputs_unpacked = unpack(packed, ps, 'i j * k')
>>> [x.shape for x in inputs_unpacked]
[(2, 3, 5), (2, 3, 7, 5), (2, 3, 7, 9, 5)]
```
Read the tutorial for introduction and application scenarios.
"""
n_axes_before, n_axes_after, min_axes = analyze_pattern(pattern, opname="unpack")
backend = get_backend(tensor)
input_shape = backend.shape(tensor)
if len(input_shape) != n_axes_before + 1 + n_axes_after:
raise EinopsError(f"unpack(..., {pattern}) received input of wrong dim with shape {input_shape}")
unpacked_axis: int = n_axes_before
lengths_of_composed_axes: List[int] = [-1 if -1 in p_shape else prod(p_shape) for p_shape in packed_shapes]
n_unknown_composed_axes = sum(int(x == -1) for x in lengths_of_composed_axes)
if n_unknown_composed_axes > 1:
raise EinopsError(
f"unpack(..., {pattern}) received more than one -1 in {packed_shapes} and can't infer dimensions"
)
# following manipulations allow to skip some shape verifications
# and leave it to backends
# [[], [2, 3], [4], [-1, 5], [6]] < examples of packed_axis
# split positions when computed should be
# [0, 1, 7, 11, N-6 , N ], where N = length of axis
split_positions = [0] * len(packed_shapes) + [input_shape[unpacked_axis]]
if n_unknown_composed_axes == 0:
for i, x in enumerate(lengths_of_composed_axes[:-1]):
split_positions[i + 1] = split_positions[i] + x
else:
unknown_composed_axis: int = lengths_of_composed_axes.index(-1)
for i in range(unknown_composed_axis):
split_positions[i + 1] = split_positions[i] + lengths_of_composed_axes[i]
for j in range(unknown_composed_axis + 1, len(lengths_of_composed_axes))[::-1]:
split_positions[j] = split_positions[j + 1] - lengths_of_composed_axes[j]
shape_start = input_shape[:unpacked_axis]
shape_end = input_shape[unpacked_axis + 1 :]
slice_filler = (slice(None, None),) * unpacked_axis
try:
return [
backend.reshape(
# shortest way slice arbitrary axis
tensor[(*slice_filler, slice(split_positions[i], split_positions[i + 1]))],
(*shape_start, *element_shape, *shape_end),
)
for i, element_shape in enumerate(packed_shapes)
]
except Exception as e:
# this hits if there is an error during reshapes, which means passed shapes were incorrect
raise EinopsError(
f'Error during unpack(..., "{pattern}"): could not split axis of size {split_positions[-1]}'
f" into requested {packed_shapes}"
) from e
@@ -0,0 +1,158 @@
import keyword
import warnings
from typing import List, Optional, Set, Tuple, Union
from einops import EinopsError
_ellipsis: str = "…" # NB, this is a single unicode symbol. String is used as it is not a list, but can be iterated
class AnonymousAxis:
"""Important thing: all instances of this class are not equal to each other"""
def __init__(self, value: str):
self.value = int(value)
if self.value <= 1:
if self.value == 1:
raise EinopsError("No need to create anonymous axis of length 1. Report this as an issue")
else:
raise EinopsError(f"Anonymous axis should have positive length, not {self.value}")
def __repr__(self):
return f"{str(self.value)}-axis"
class ParsedExpression:
"""
non-mutable structure that contains information about one side of expression (e.g. 'b c (h w)')
and keeps some information important for downstream
"""
def __init__(self, expression: str, *, allow_underscore: bool = False, allow_duplicates: bool = False):
self.has_ellipsis: bool = False
self.has_ellipsis_parenthesized: Optional[bool] = None
self.identifiers: Set[str] = set()
# that's axes like 2, 3, 4 or 5. Axes with size 1 are exceptional and replaced with empty composition
self.has_non_unitary_anonymous_axes: bool = False
# composition keeps structure of composite axes, see how different corner cases are handled in tests
self.composition: List[Union[List[str], str]] = []
if "." in expression:
if "..." not in expression:
raise EinopsError("Expression may contain dots only inside ellipsis (...)")
if str.count(expression, "...") != 1 or str.count(expression, ".") != 3:
raise EinopsError(
"Expression may contain dots only inside ellipsis (...); only one ellipsis for tensor "
)
expression = expression.replace("...", _ellipsis)
self.has_ellipsis = True
bracket_group: Optional[List[str]] = None
def add_axis_name(x):
if x in self.identifiers:
if not (allow_underscore and x == "_") and not allow_duplicates:
raise EinopsError(f'Indexing expression contains duplicate dimension "{x}"')
if x == _ellipsis:
self.identifiers.add(_ellipsis)
if bracket_group is None:
self.composition.append(_ellipsis)
self.has_ellipsis_parenthesized = False
else:
bracket_group.append(_ellipsis)
self.has_ellipsis_parenthesized = True
else:
is_number = str.isdecimal(x)
if is_number and int(x) == 1:
# handling the case of anonymous axis of length 1
if bracket_group is None:
self.composition.append([])
else:
pass # no need to think about 1s inside parenthesis
return
is_axis_name, reason = self.check_axis_name_return_reason(x, allow_underscore=allow_underscore)
if not (is_number or is_axis_name):
raise EinopsError(f"Invalid axis identifier: {x}\n{reason}")
if is_number:
x = AnonymousAxis(x)
self.identifiers.add(x)
if is_number:
self.has_non_unitary_anonymous_axes = True
if bracket_group is None:
self.composition.append([x])
else:
bracket_group.append(x)
current_identifier = None
for char in expression:
if char in "() ":
if current_identifier is not None:
add_axis_name(current_identifier)
current_identifier = None
if char == "(":
if bracket_group is not None:
raise EinopsError("Axis composition is one-level (brackets inside brackets not allowed)")
bracket_group = []
elif char == ")":
if bracket_group is None:
raise EinopsError("Brackets are not balanced")
self.composition.append(bracket_group)
bracket_group = None
elif str.isalnum(char) or char in ["_", _ellipsis]:
if current_identifier is None:
current_identifier = char
else:
current_identifier += char
else:
raise EinopsError(f"Unknown character '{char}'")
if bracket_group is not None:
raise EinopsError(f'Imbalanced parentheses in expression: "{expression}"')
if current_identifier is not None:
add_axis_name(current_identifier)
def flat_axes_order(self) -> List:
result = []
for composed_axis in self.composition:
assert isinstance(composed_axis, list), "does not work with ellipsis"
for axis in composed_axis:
result.append(axis)
return result
def has_composed_axes(self) -> bool:
# this will ignore 1 inside brackets
for axes in self.composition:
if isinstance(axes, list) and len(axes) > 1:
return True
return False
@staticmethod
def check_axis_name_return_reason(name: str, allow_underscore: bool = False) -> Tuple[bool, str]:
if not str.isidentifier(name):
return False, "not a valid python identifier"
elif name[0] == "_" or name[-1] == "_":
if name == "_" and allow_underscore:
return True, ""
return False, "axis name should should not start or end with underscore"
else:
if keyword.iskeyword(name):
warnings.warn(
f"It is discouraged to use axes names that are keywords: {name}",
RuntimeWarning,
stacklevel=2,
)
if name in ["axis"]:
warnings.warn(
"It is discouraged to use 'axis' as an axis name and will raise an error in future",
FutureWarning,
stacklevel=2,
)
return True, ""
@staticmethod
def check_axis_name(name: str) -> bool:
"""
Valid axes names are python identifiers except keywords,
and additionally should not start or end with underscore
"""
is_valid, _reason = ParsedExpression.check_axis_name_return_reason(name)
return is_valid
@@ -0,0 +1,109 @@
"""
Common utils for testing.
These functions allow testing only some frameworks, not all.
"""
import logging
import os
import warnings
from functools import lru_cache
from typing import List, Tuple
from einops import _backends
__author__ = "Alex Rogozhnikov"
# minimize noise in tests logging
logging.getLogger("tensorflow").disabled = True
logging.getLogger("matplotlib").disabled = True
FLOAT_REDUCTIONS = ("min", "max", "sum", "mean", "prod") # not includes any/all
def find_names_of_all_frameworks() -> List[str]:
backend_subclasses = []
backends = _backends.AbstractBackend.__subclasses__()
while backends:
backend = backends.pop()
backends += backend.__subclasses__()
backend_subclasses.append(backend)
return [b.framework_name for b in backend_subclasses]
ENVVAR_NAME = "EINOPS_TEST_BACKENDS"
def unparse_backends(backend_names: List[str]) -> Tuple[str, str]:
_known_backends = find_names_of_all_frameworks()
for backend_name in backend_names:
if backend_name not in _known_backends:
raise RuntimeError(f"Unknown framework: {backend_name}")
return ENVVAR_NAME, ",".join(backend_names)
@lru_cache(maxsize=1)
def parse_backends_to_test() -> List[str]:
if ENVVAR_NAME not in os.environ:
raise RuntimeError(f"Testing frameworks were not specified, env var {ENVVAR_NAME} not set")
parsed_backends = os.environ[ENVVAR_NAME].split(",")
_known_backends = find_names_of_all_frameworks()
for backend_name in parsed_backends:
if backend_name not in _known_backends:
raise RuntimeError(f"Unknown framework: {backend_name}")
return parsed_backends
def is_backend_tested(backend: str) -> bool:
"""Used to skip test if corresponding backend is not tested"""
if backend not in find_names_of_all_frameworks():
raise RuntimeError(f"Unknown framework {backend}")
return backend in parse_backends_to_test()
def collect_test_backends(symbolic=False, layers=False) -> List[_backends.AbstractBackend]:
"""
:param symbolic: symbolic or imperative frameworks?
:param layers: layers or operations?
:return: list of backends satisfying set conditions
"""
if not symbolic:
if not layers:
backend_types = [
_backends.NumpyBackend,
_backends.JaxBackend,
_backends.TorchBackend,
_backends.TensorflowBackend,
_backends.OneFlowBackend,
_backends.PaddleBackend,
_backends.CupyBackend,
]
else:
backend_types = [
_backends.TorchBackend,
_backends.OneFlowBackend,
_backends.PaddleBackend,
]
else:
if not layers:
backend_types = [
_backends.PyTensorBackend,
]
else:
backend_types = [
_backends.TFKerasBackend,
]
backend_names_to_test = parse_backends_to_test()
result = []
for backend_type in backend_types:
if backend_type.framework_name not in backend_names_to_test:
continue
try:
result.append(backend_type())
except ImportError:
# problem with backend installation fails a specific test function,
# but will be skipped in all other test cases
warnings.warn(f"backend could not be initialized for tests: {backend_type}", stacklevel=1)
return result
@@ -0,0 +1,89 @@
"""
Runs tests that are appropriate for framework.
"""
import os
import sys
from pathlib import Path
from subprocess import Popen
__author__ = "Alex Rogozhnikov"
def run(cmd, **env):
# keeps printing output when testing
cmd = cmd.split(" ") if isinstance(cmd, str) else cmd
print("running:", cmd)
p = Popen(cmd, cwd=str(Path(__file__).parent), env={**os.environ, **env})
p.communicate()
return p.returncode
def main():
_executable, *args = sys.argv
frameworks = [x for x in args if x != "--pip-install"]
pip_install_is_set = "--pip-install" in args
framework_name2installation = {
"numpy": ["numpy"],
"torch": ["torch --index-url https://download.pytorch.org/whl/cpu"],
"jax": ["jax[cpu]", "flax"],
"tensorflow": ["tensorflow"],
"cupy": ["cupy"],
# switch to stable paddlepaddle, because of https://github.com/PaddlePaddle/Paddle/issues/63927
# "paddle": ["paddlepaddle==0.0.0 -f https://www.paddlepaddle.org.cn/whl/linux/cpu-mkl/develop.html"],
"paddle": ["paddlepaddle"],
"oneflow": ["oneflow==0.9.0"],
"pytensor": ["pytensor"],
}
if sys.platform == "darwin":
framework_name2installation["mlx"] = ["mlx"]
if sys.platform.startswith("linux"):
framework_name2installation["mlx"] = ["mlx[cpu]"]
usage = f"""
Usage: python -m einops.tests.run_tests <frameworks> [--pip-install]
Example: python -m einops.tests.run_tests numpy pytorch --pip-install
Available frameworks: {list(framework_name2installation)}
When --pip-install is set, auto-installs requirements with pip.
(make sure which pip points to right pip)
"""
if len(frameworks) == 0:
print(usage)
return
else:
synonyms = {
"tf": "tensorflow",
"pytorch": "torch",
"paddlepaddle": "paddle",
}
frameworks = [synonyms.get(f, f) for f in frameworks]
wrong_frameworks = [f for f in frameworks if f not in framework_name2installation]
if wrong_frameworks:
print(usage)
raise RuntimeError(f"Unrecognized frameworks: {wrong_frameworks}")
if pip_install_is_set:
print("Install testing infra")
other_dependencies = ["pytest"]
assert 0 == run("pip install {} --progress-bar off -q".format(" ".join(other_dependencies)))
for framework in frameworks:
print(f"Installing {framework}")
pip_instructions = framework_name2installation[framework]
assert 0 == run("pip install {} --progress-bar off -q".format(" ".join(pip_instructions)))
# we need to inform testing script which frameworks to use
# this is done by setting an envvar EINOPS_TEST_BACKENDS
from einops.tests import unparse_backends
envvar_name, envvar_value = unparse_backends(backend_names=frameworks)
return_code = run(
"python -m pytest .",
**{envvar_name: envvar_value},
)
assert return_code == 0
if __name__ == "__main__":
main()
@@ -0,0 +1,356 @@
import string
from typing import Any, Callable
import numpy as np
import pytest
from einops.einops import EinopsError, _compactify_pattern_for_einsum, einsum
from einops.tests import collect_test_backends
class Arguments:
def __init__(self, *args: Any, **kargs: Any):
self.args = args
self.kwargs = kargs
def __call__(self, function: Callable):
return function(*self.args, **self.kwargs)
test_layer_cases = [
(
Arguments("b c_in h w -> w c_out h b", "c_in c_out", bias_shape=None, c_out=13, c_in=12),
(2, 12, 3, 4),
(4, 13, 3, 2),
),
(
Arguments("b c_in h w -> w c_out h b", "c_in c_out", bias_shape="c_out", c_out=13, c_in=12),
(2, 12, 3, 4),
(4, 13, 3, 2),
),
(
Arguments("b c_in h w -> w c_in h b", "", bias_shape=None, c_in=12),
(2, 12, 3, 4),
(4, 12, 3, 2),
),
(
Arguments("b c_in h w -> b c_out", "c_in h w c_out", bias_shape=None, c_in=12, h=3, w=4, c_out=5),
(2, 12, 3, 4),
(2, 5),
),
(
Arguments("b t head c_in -> b t head c_out", "head c_in c_out", bias_shape=None, head=4, c_in=5, c_out=6),
(2, 3, 4, 5),
(2, 3, 4, 6),
),
]
# Each of the form:
# (Arguments, true_einsum_pattern, in_shapes, out_shape)
test_functional_cases = [
(
# Basic:
"b c h w, b w -> b h",
"abcd,ad->ac",
((2, 3, 4, 5), (2, 5)),
(2, 4),
),
(
# Three tensors:
"b c h w, b w, b c -> b h",
"abcd,ad,ab->ac",
((2, 3, 40, 5), (2, 5), (2, 3)),
(2, 40),
),
(
# Ellipsis, and full names:
"... one two three, three four five -> ... two five",
"...abc,cde->...be",
((32, 5, 2, 3, 4), (4, 5, 6)),
(32, 5, 3, 6),
),
(
# Ellipsis at the end:
"one two three ..., three four five -> two five ...",
"abc...,cde->be...",
((2, 3, 4, 32, 5), (4, 5, 6)),
(3, 6, 32, 5),
),
(
# Ellipsis on multiple tensors:
"... one two three, ... three four five -> ... two five",
"...abc,...cde->...be",
((32, 5, 2, 3, 4), (32, 5, 4, 5, 6)),
(32, 5, 3, 6),
),
(
# One tensor, and underscores:
"first_tensor second_tensor -> first_tensor",
"ab->a",
((5, 4),),
(5,),
),
(
# Trace (repeated index)
"i i -> ",
"aa->",
((5, 5),),
(),
),
(
# Too many spaces in string:
" one two , three four->two four ",
"ab,cd->bd",
((2, 3), (4, 5)),
(3, 5),
),
# The following tests were inspired by numpy's einsum tests
# https://github.com/numpy/numpy/blob/v1.23.0/numpy/core/tests/test_einsum.py
(
# Trace with other indices
"i middle i -> middle",
"aba->b",
((5, 10, 5),),
(10,),
),
(
# Ellipsis in the middle:
"i ... i -> ...",
"a...a->...",
((5, 3, 2, 1, 4, 5),),
(3, 2, 1, 4),
),
(
# Product of first and last axes:
"i ... i -> i ...",
"a...a->a...",
((5, 3, 2, 1, 4, 5),),
(5, 3, 2, 1, 4),
),
(
# Triple diagonal
"one one one -> one",
"aaa->a",
((5, 5, 5),),
(5,),
),
(
# Axis swap:
"i j k -> j i k",
"abc->bac",
((1, 2, 3),),
(2, 1, 3),
),
(
# Identity:
"... -> ...",
"...->...",
((5, 4, 3, 2, 1),),
(5, 4, 3, 2, 1),
),
(
# Elementwise product of three tensors
"..., ..., ... -> ...",
"...,...,...->...",
((3, 2), (3, 2), (3, 2)),
(3, 2),
),
(
# Basic summation:
"index ->",
"a->",
((10,)),
(()),
),
]
def test_layer():
for backend in collect_test_backends(layers=True, symbolic=False):
rng = np.random.default_rng()
if backend.framework_name in ["tensorflow", "torch", "oneflow", "paddle"]:
layer_type = backend.layers().EinMix
for args, in_shape, out_shape in test_layer_cases:
layer = args(layer_type)
print("Running", layer.einsum_pattern, "for", backend.framework_name)
input = rng.uniform(size=in_shape).astype("float32")
input_framework = backend.from_numpy(input)
output_framework = layer(input_framework)
output = backend.to_numpy(output_framework)
assert output.shape == out_shape
valid_backends_functional = [
"tensorflow",
"torch",
"jax",
"numpy",
"oneflow",
"cupy",
"tensorflow.keras",
"paddle",
"pytensor",
"mlx",
]
def test_functional():
# Functional tests:
backends = filter(lambda x: x.framework_name in valid_backends_functional, collect_test_backends())
for backend in backends:
for einops_pattern, true_pattern, in_shapes, out_shape in test_functional_cases:
print(f"Running '{einops_pattern}' for {backend.framework_name}")
# Create pattern:
predicted_pattern = _compactify_pattern_for_einsum(einops_pattern)
assert predicted_pattern == true_pattern
# Generate example data:
rstate = np.random.RandomState(0)
in_arrays = [rstate.uniform(size=shape).astype("float32") for shape in in_shapes]
in_arrays_framework = [backend.from_numpy(array) for array in in_arrays]
# Loop over whether we call it manually with the backend,
# or whether we use `einops.einsum`.
for do_manual_call in [True, False]:
# Actually run einsum:
if do_manual_call:
out_array = backend.einsum(predicted_pattern, *in_arrays_framework)
else:
out_array = einsum(*in_arrays_framework, einops_pattern)
# Check shape:
if tuple(out_array.shape) != out_shape:
raise ValueError(f"Expected output shape {out_shape} but got {out_array.shape}")
# Check values:
true_out_array = np.einsum(true_pattern, *in_arrays)
predicted_out_array = backend.to_numpy(out_array)
np.testing.assert_array_almost_equal(predicted_out_array, true_out_array, decimal=5)
def test_functional_symbolic():
backends = filter(
lambda x: x.framework_name in valid_backends_functional, collect_test_backends(symbolic=True, layers=False)
)
for backend in backends:
for einops_pattern, true_pattern, in_shapes, out_shape in test_functional_cases:
print(f"Running '{einops_pattern}' for symbolic {backend.framework_name}")
# Create pattern:
predicted_pattern = _compactify_pattern_for_einsum(einops_pattern)
assert predicted_pattern == true_pattern
rstate = np.random.RandomState(0)
in_syms = [backend.create_symbol(in_shape) for in_shape in in_shapes]
in_data = [rstate.uniform(size=in_shape).astype("float32") for in_shape in in_shapes]
expected_out_data = np.einsum(true_pattern, *in_data)
for do_manual_call in [True, False]:
if do_manual_call:
predicted_out_symbol = backend.einsum(predicted_pattern, *in_syms)
else:
predicted_out_symbol = einsum(*in_syms, einops_pattern)
predicted_out_data = backend.eval_symbol(
predicted_out_symbol,
list(zip(in_syms, in_data)),
)
if predicted_out_data.shape != out_shape:
raise ValueError(f"Expected output shape {out_shape} but got {predicted_out_data.shape}")
np.testing.assert_array_almost_equal(predicted_out_data, expected_out_data, decimal=5)
def test_functional_errors():
# Specific backend does not matter, as errors are raised
# during the pattern creation.
rstate = np.random.RandomState(0)
def create_tensor(*shape):
return rstate.uniform(size=shape).astype("float32")
# raise NotImplementedError("Singleton () axes are not yet supported in einsum.")
with pytest.raises(NotImplementedError, match="^Singleton"):
einsum(
create_tensor(5, 1),
"i () -> i",
)
# raise NotImplementedError("Shape rearrangement is not yet supported in einsum.")
with pytest.raises(NotImplementedError, match="^Shape rearrangement"):
einsum(
create_tensor(5, 1),
"a b -> (a b)",
)
with pytest.raises(NotImplementedError, match="^Shape rearrangement"):
einsum(
create_tensor(10, 1),
"(a b) -> a b",
)
# raise RuntimeError("Encountered empty axis name in einsum.")
# raise RuntimeError("Axis name in einsum must be a string.")
# ^ Not tested, these are just a failsafe in case an unexpected error occurs.
# raise NotImplementedError("Anonymous axes are not yet supported in einsum.")
with pytest.raises(NotImplementedError, match="^Anonymous axes"):
einsum(
create_tensor(5, 1),
"i 2 -> i",
)
# ParsedExpression error:
with pytest.raises(EinopsError, match="^Invalid axis identifier"):
einsum(
create_tensor(5, 1),
"i 2j -> i",
)
# raise ValueError("Einsum pattern must contain '->'.")
with pytest.raises(ValueError, match="^Einsum pattern"):
einsum(
create_tensor(5, 3, 2),
"i j k",
)
# raise RuntimeError("Too many axes in einsum.")
with pytest.raises(RuntimeError, match="^Too many axes"):
einsum(
create_tensor(1),
" ".join(string.ascii_letters) + " extra ->",
)
# raise RuntimeError("Unknown axis on right side of einsum.")
with pytest.raises(RuntimeError, match="^Unknown axis"):
einsum(
create_tensor(5, 1),
"i j -> k",
)
# raise ValueError(
# "The last argument passed to `einops.einsum` must be a string,"
# " representing the einsum pattern."
# )
with pytest.raises(ValueError, match="^The last argument"):
einsum(
"i j k -> i",
create_tensor(5, 4, 3),
)
# raise ValueError(
# "`einops.einsum` takes at minimum two arguments: the tensors,"
# " followed by the pattern."
# )
with pytest.raises(ValueError, match="^`einops.einsum` takes"):
einsum(
"i j k -> i",
)
with pytest.raises(ValueError, match="^`einops.einsum` takes"):
einsum(
create_tensor(5, 1),
)
# TODO: Include check for giving normal einsum pattern rather than einops.
@@ -0,0 +1,297 @@
import numpy as np
import pytest
from einops import parse_shape, rearrange, reduce
from einops.tests import is_backend_tested
from einops.tests.test_ops import imp_op_backends
def test_rearrange_examples():
def test1(x):
# transpose
y = rearrange(x, "b c h w -> b h w c")
assert tuple(y.shape) == (10, 30, 40, 20)
return y
def test2(x):
# view / reshape
y = rearrange(x, "b c h w -> b (c h w)")
assert tuple(y.shape) == (10, 20 * 30 * 40)
return y
def test3(x):
# depth-to-space
y = rearrange(x, "b (c h1 w1) h w -> b c (h h1) (w w1)", h1=2, w1=2)
assert tuple(y.shape) == (10, 5, 30 * 2, 40 * 2)
return y
def test4(x):
# space-to-depth
y = rearrange(x, "b c (h h1) (w w1) -> b (h1 w1 c) h w", h1=2, w1=2)
assert tuple(y.shape) == (10, 20 * 4, 30 // 2, 40 // 2)
return y
def test5(x):
# simple transposition
y = rearrange(x, "b1 sound b2 letter -> b1 b2 sound letter")
assert tuple(y.shape) == (10, 30, 20, 40)
return y
def test6(x):
# parsing parameters
t = rearrange(x, "b c h w -> (b h w) c")
t = t[:, ::2] # replacement for dot-product, just changes size of second axis
assert tuple(t.shape) == (10 * 30 * 40, 10)
y = rearrange(t, "(b h w) c2 -> b c2 h w", **parse_shape(x, "b _ h w"))
assert tuple(y.shape) == (10, 10, 30, 40)
return y
def test7(x):
# split of embedding into groups
y1, y2 = rearrange(x, "b (c g) h w -> g b c h w", g=2)
assert tuple(y1.shape) == (10, 10, 30, 40)
assert tuple(y2.shape) == (10, 10, 30, 40)
return y1 + y2 # only one tensor is expected in output
def test8(x):
# max-pooling
y = reduce(x, "b c (h h1) (w w1) -> b c h w", reduction="max", h1=2, w1=2)
assert tuple(y.shape) == (10, 20, 30 // 2, 40 // 2)
return y
def test9(x):
# squeeze - unsqueeze
y = reduce(x, "b c h w -> b c () ()", reduction="max")
assert tuple(y.shape) == (10, 20, 1, 1)
y = rearrange(y, "b c () () -> c b")
assert tuple(y.shape) == (20, 10)
return y
def test10(x):
# stack
tensors = list(x + 0) # 0 is needed https://github.com/tensorflow/tensorflow/issues/23185
tensors = rearrange(tensors, "b c h w -> b h w c")
assert tuple(tensors.shape) == (10, 30, 40, 20)
return tensors
def test11(x):
# concatenate
tensors = list(x + 0) # 0 is needed https://github.com/tensorflow/tensorflow/issues/23185
tensors = rearrange(tensors, "b c h w -> h (b w) c")
assert tuple(tensors.shape) == (30, 10 * 40, 20)
return tensors
def shufflenet(x, convolve, c1, c2):
# shufflenet reordering example
x = convolve(x)
x = rearrange(x, "b (c1 c2) h w-> b (c2 c1) h w", c1=c1, c2=c2)
x = convolve(x)
return x
def convolve_strided_1d(x, stride, usual_convolution):
x = rearrange(x, "b c t1 t2 -> b c (t1 t2)") # reduce dimensionality
x = rearrange(x, "b c (t stride) -> (stride b) c t", stride=stride)
x = usual_convolution(x)
x = rearrange(x, "(stride b) c t -> b c (t stride)", stride=stride)
return x
def convolve_strided_2d(x, h_stride, w_stride, usual_convolution):
x = rearrange(x, "b c (h hs) (w ws) -> (hs ws b) c h w", hs=h_stride, ws=w_stride)
x = usual_convolution(x)
x = rearrange(x, "(hs ws b) c h w -> b c (h hs) (w ws)", hs=h_stride, ws=w_stride)
return x
def unet_like_1d(x, usual_convolution):
# u-net like steps for increasing / reducing dimensionality
x = rearrange(x, "b c t1 t2 -> b c (t1 t2)") # reduce dimensionality
y = rearrange(x, "b c (t dt) -> b (dt c) t", dt=2)
y = usual_convolution(y)
x = x + rearrange(y, "b (dt c) t -> b c (t dt)", dt=2)
return x
# mock for convolution (works for all backends)
def convolve_mock(x):
return x
tests = [
test1,
test2,
test3,
test4,
test5,
test6,
test7,
test8,
test9,
test10,
test11,
lambda x: shufflenet(x, convolve=convolve_mock, c1=4, c2=5),
lambda x: convolve_strided_1d(x, stride=2, usual_convolution=convolve_mock),
lambda x: convolve_strided_2d(x, h_stride=2, w_stride=2, usual_convolution=convolve_mock),
lambda x: unet_like_1d(x, usual_convolution=convolve_mock),
]
for backend in imp_op_backends:
print("testing source_examples for ", backend.framework_name)
for test in tests:
x = np.arange(10 * 20 * 30 * 40).reshape([10, 20, 30, 40])
result1 = test(x)
result2 = backend.to_numpy(test(backend.from_numpy(x)))
assert np.array_equal(result1, result2)
# now with strides
x = np.arange(10 * 2 * 20 * 3 * 30 * 1 * 40).reshape([10 * 2, 20 * 3, 30 * 1, 40 * 1])
# known torch bug - torch doesn't support negative steps
last_step = -1 if (backend.framework_name != "torch" and backend.framework_name != "oneflow") else 1
indexing_expression = np.index_exp[::2, ::3, ::1, ::last_step]
result1 = test(x[indexing_expression])
result2 = backend.to_numpy(test(backend.from_numpy(x)[indexing_expression]))
assert np.array_equal(result1, result2)
def tensor_train_example_numpy():
# kept here just for a collection, only tested for numpy
# https://arxiv.org/pdf/1509.06569.pdf, (5)
x = np.ones([3, 4, 5, 6])
rank = 4
if np.__version__ < "1.15.0":
# numpy.einsum fails here, skip test
return
# creating appropriate Gs
Gs = [np.ones([d, d, rank, rank]) for d in x.shape]
Gs[0] = Gs[0][:, :, :1, :]
Gs[-1] = Gs[-1][:, :, :, :1]
# einsum way
y = x.reshape((1, *x.shape))
for G in Gs:
# taking partial results left-to-right
# y = numpy.einsum('i j alpha beta, alpha i ... -> beta ... j', G, y)
y = np.einsum("i j a b, a i ... -> b ... j", G, y)
y1 = y.reshape(-1)
# alternative way
y = x.reshape(-1)
for G in Gs:
i, j, alpha, beta = G.shape
y = rearrange(y, "(i rest alpha) -> rest (alpha i)", alpha=alpha, i=i)
y = y @ rearrange(G, "i j alpha beta -> (alpha i) (j beta)")
y = rearrange(y, "rest (beta j) -> (beta rest j)", beta=beta, j=j)
y2 = y
assert np.allclose(y1, y2)
# yet another way
y = x
for G in Gs:
i, j, alpha, beta = G.shape
y = rearrange(y, "i ... (j alpha) -> ... j (alpha i)", alpha=alpha, i=i)
y = y @ rearrange(G, "i j alpha beta -> (alpha i) (j beta)")
y3 = y.reshape(-1)
assert np.allclose(y1, y3)
def test_pytorch_yolo_fragment():
if not is_backend_tested("torch"):
pytest.skip()
import torch
def old_way(tensor, num_classes, num_anchors, anchors, stride_h, stride_w):
# https://github.com/BobLiu20/YOLOv3_PyTorch/blob/c6b483743598b5f64d520d81e7e5f47ba936d4c9/nets/yolo_loss.py#L28-L44
bs = tensor.size(0)
in_h = tensor.size(2)
in_w = tensor.size(3)
scaled_anchors = [(a_w / stride_w, a_h / stride_h) for a_w, a_h in anchors]
prediction = tensor.view(bs, num_anchors, 5 + num_classes, in_h, in_w).permute(0, 1, 3, 4, 2).contiguous()
# Get outputs
x = torch.sigmoid(prediction[..., 0]) # Center x
y = torch.sigmoid(prediction[..., 1]) # Center y
w = prediction[..., 2] # Width
h = prediction[..., 3] # Height
conf = torch.sigmoid(prediction[..., 4]) # Conf
pred_cls = torch.sigmoid(prediction[..., 5:]) # Cls pred.
# https://github.com/BobLiu20/YOLOv3_PyTorch/blob/c6b483743598b5f64d520d81e7e5f47ba936d4c9/nets/yolo_loss.py#L70-L92
FloatTensor = torch.cuda.FloatTensor if x.is_cuda else torch.FloatTensor
LongTensor = torch.cuda.LongTensor if x.is_cuda else torch.LongTensor
# Calculate offsets for each grid
grid_x = (
torch.linspace(0, in_w - 1, in_w)
.repeat(in_w, 1)
.repeat(bs * num_anchors, 1, 1)
.view(x.shape)
.type(FloatTensor)
)
grid_y = (
torch.linspace(0, in_h - 1, in_h)
.repeat(in_h, 1)
.t()
.repeat(bs * num_anchors, 1, 1)
.view(y.shape)
.type(FloatTensor)
)
# Calculate anchor w, h
anchor_w = FloatTensor(scaled_anchors).index_select(1, LongTensor([0]))
anchor_h = FloatTensor(scaled_anchors).index_select(1, LongTensor([1]))
anchor_w = anchor_w.repeat(bs, 1).repeat(1, 1, in_h * in_w).view(w.shape)
anchor_h = anchor_h.repeat(bs, 1).repeat(1, 1, in_h * in_w).view(h.shape)
# Add offset and scale with anchors
pred_boxes = FloatTensor(prediction[..., :4].shape)
pred_boxes[..., 0] = x.data + grid_x
pred_boxes[..., 1] = y.data + grid_y
pred_boxes[..., 2] = torch.exp(w.data) * anchor_w
pred_boxes[..., 3] = torch.exp(h.data) * anchor_h
# Results
_scale = torch.Tensor([stride_w, stride_h] * 2).type(FloatTensor)
output = torch.cat(
(pred_boxes.view(bs, -1, 4) * _scale, conf.view(bs, -1, 1), pred_cls.view(bs, -1, num_classes)), -1
)
return output
def new_way(tensor, num_classes, num_anchors, anchors, stride_h, stride_w):
raw_predictions = rearrange(tensor, " b (anchor prediction) h w -> prediction b anchor h w", anchor=num_anchors)
anchors = torch.FloatTensor(anchors).to(tensor.device)
anchor_sizes = rearrange(anchors, "anchor dim -> dim () anchor () ()")
_, _, _, in_h, in_w = raw_predictions.shape
grid_h = rearrange(torch.arange(in_h).float(), "h -> () () h ()").to(tensor.device)
grid_w = rearrange(torch.arange(in_w).float(), "w -> () () () w").to(tensor.device)
predicted_bboxes = torch.zeros_like(raw_predictions)
predicted_bboxes[0] = (raw_predictions[0].sigmoid() + grid_h) * stride_h # center y
predicted_bboxes[1] = (raw_predictions[1].sigmoid() + grid_w) * stride_w # center x
predicted_bboxes[2:4] = (raw_predictions[2:4].exp()) * anchor_sizes # bbox width and height
predicted_bboxes[4] = raw_predictions[4].sigmoid() # confidence
predicted_bboxes[5:] = raw_predictions[5:].sigmoid() # class predictions
# only to match results of original code, not needed
return rearrange(predicted_bboxes, "prediction b anchor h w -> b anchor h w prediction")
stride_h = 4
stride_w = 4
batch_size = 5
num_classes = 12
anchors = [[50, 100], [100, 50], [75, 75]]
num_anchors = len(anchors)
x = torch.randn([batch_size, num_anchors * (5 + num_classes), 1, 1])
result1 = old_way(
tensor=x,
num_anchors=num_anchors,
num_classes=num_classes,
stride_h=stride_h,
stride_w=stride_w,
anchors=anchors,
)
result2 = new_way(
tensor=x,
num_anchors=num_anchors,
num_classes=num_classes,
stride_h=stride_h,
stride_w=stride_w,
anchors=anchors,
)
result1 = result1.reshape(result2.shape)
assert torch.allclose(result1, result2)
@@ -0,0 +1,480 @@
import pickle
from collections import namedtuple
import numpy as np
import pytest
from einops import EinopsError, rearrange, reduce
from einops.tests import FLOAT_REDUCTIONS as REDUCTIONS
from einops.tests import collect_test_backends, is_backend_tested
__author__ = "Alex Rogozhnikov"
testcase = namedtuple("testcase", ["pattern", "axes_lengths", "input_shape", "wrong_shapes"])
rearrangement_patterns = [
testcase(
"b c h w -> b (c h w)",
dict(c=20),
(10, 20, 30, 40),
[(), (10,), (10, 10, 10), (10, 21, 30, 40), [1, 20, 1, 1, 1]],
),
testcase(
"b c (h1 h2) (w1 w2) -> b (c h2 w2) h1 w1",
dict(h2=2, w2=2),
(10, 20, 30, 40),
[(), (1, 1, 1, 1), (1, 10, 3), ()],
),
testcase(
"b ... c -> c b ...",
dict(b=10),
(10, 20, 30),
[(), (10,), (5, 10)],
),
]
def test_rearrange_imperative():
for backend in collect_test_backends(symbolic=False, layers=True):
print("Test layer for ", backend.framework_name)
for pattern, axes_lengths, input_shape, wrong_shapes in rearrangement_patterns:
x = np.arange(np.prod(input_shape), dtype="float32").reshape(input_shape)
result_numpy = rearrange(x, pattern, **axes_lengths)
layer = backend.layers().Rearrange(pattern, **axes_lengths)
for shape in wrong_shapes:
try:
layer(backend.from_numpy(np.zeros(shape, dtype="float32")))
except BaseException:
pass
else:
raise AssertionError("Failure expected")
# simple pickling / unpickling
layer2 = pickle.loads(pickle.dumps(layer))
result1 = backend.to_numpy(layer(backend.from_numpy(x)))
result2 = backend.to_numpy(layer2(backend.from_numpy(x)))
assert np.allclose(result_numpy, result1)
assert np.allclose(result1, result2)
just_sum = backend.layers().Reduce("...->", reduction="sum")
variable = backend.from_numpy(x)
result = just_sum(layer(variable))
result.backward()
assert np.allclose(backend.to_numpy(variable.grad), 1)
def test_rearrange_symbolic():
for backend in collect_test_backends(symbolic=True, layers=True):
print("Test layer for ", backend.framework_name)
for pattern, axes_lengths, input_shape, _wrong_shapes in rearrangement_patterns:
x = np.arange(np.prod(input_shape), dtype="float32").reshape(input_shape)
result_numpy = rearrange(x, pattern, **axes_lengths)
layer = backend.layers().Rearrange(pattern, **axes_lengths)
input_shape_of_nones = [None] * len(input_shape)
shapes = [input_shape, input_shape_of_nones]
for shape in shapes:
symbol = backend.create_symbol(shape)
eval_inputs = [(symbol, x)]
result_symbol1 = layer(symbol)
result1 = backend.eval_symbol(result_symbol1, eval_inputs)
assert np.allclose(result_numpy, result1)
layer2 = pickle.loads(pickle.dumps(layer))
result_symbol2 = layer2(symbol)
result2 = backend.eval_symbol(result_symbol2, eval_inputs)
assert np.allclose(result1, result2)
# now testing back-propagation
just_sum = backend.layers().Reduce("...->", reduction="sum")
result_sum1 = backend.eval_symbol(just_sum(result_symbol1), eval_inputs)
result_sum2 = np.sum(x)
assert np.allclose(result_sum1, result_sum2)
reduction_patterns = [
*rearrangement_patterns,
testcase("b c h w -> b ()", dict(b=10), (10, 20, 30, 40), [(10,), (10, 20, 30)]),
testcase("b c (h1 h2) (w1 w2) -> b c h1 w1", dict(h1=15, h2=2, w2=2), (10, 20, 30, 40), [(10, 20, 31, 40)]),
testcase("b ... c -> b", dict(b=10), (10, 20, 30, 40), [(10,), (11, 10)]),
]
def test_reduce_imperative():
for backend in collect_test_backends(symbolic=False, layers=True):
print("Test layer for ", backend.framework_name)
for reduction in REDUCTIONS:
for pattern, axes_lengths, input_shape, wrong_shapes in reduction_patterns:
print(backend, reduction, pattern, axes_lengths, input_shape, wrong_shapes)
x = np.arange(1, 1 + np.prod(input_shape), dtype="float32").reshape(input_shape)
x /= x.mean()
result_numpy = reduce(x, pattern, reduction, **axes_lengths)
layer = backend.layers().Reduce(pattern, reduction, **axes_lengths)
for shape in wrong_shapes:
try:
layer(backend.from_numpy(np.zeros(shape, dtype="float32")))
except BaseException:
pass
else:
raise AssertionError("Failure expected")
# simple pickling / unpickling
layer2 = pickle.loads(pickle.dumps(layer))
result1 = backend.to_numpy(layer(backend.from_numpy(x)))
result2 = backend.to_numpy(layer2(backend.from_numpy(x)))
assert np.allclose(result_numpy, result1)
assert np.allclose(result1, result2)
just_sum = backend.layers().Reduce("...->", reduction="sum")
variable = backend.from_numpy(x)
result = just_sum(layer(variable))
result.backward()
grad = backend.to_numpy(variable.grad)
if reduction == "sum":
assert np.allclose(grad, 1)
if reduction == "mean":
assert np.allclose(grad, grad.min())
if reduction in ["max", "min"]:
assert np.all(np.isin(grad, [0, 1]))
assert np.sum(grad) > 0.5
def test_reduce_symbolic():
for backend in collect_test_backends(symbolic=True, layers=True):
print("Test layer for ", backend.framework_name)
for reduction in REDUCTIONS:
for pattern, axes_lengths, input_shape, _wrong_shapes in reduction_patterns:
x = np.arange(1, 1 + np.prod(input_shape), dtype="float32").reshape(input_shape)
x /= x.mean()
result_numpy = reduce(x, pattern, reduction, **axes_lengths)
layer = backend.layers().Reduce(pattern, reduction, **axes_lengths)
input_shape_of_nones = [None] * len(input_shape)
shapes = [input_shape, input_shape_of_nones]
for shape in shapes:
symbol = backend.create_symbol(shape)
eval_inputs = [(symbol, x)]
result_symbol1 = layer(symbol)
result1 = backend.eval_symbol(result_symbol1, eval_inputs)
assert np.allclose(result_numpy, result1)
layer2 = pickle.loads(pickle.dumps(layer))
result_symbol2 = layer2(symbol)
result2 = backend.eval_symbol(result_symbol2, eval_inputs)
assert np.allclose(result1, result2)
def create_torch_model(use_reduce=False, add_scripted_layer=False):
if not is_backend_tested("torch"):
pytest.skip()
else:
import torch.jit
from torch.nn import Conv2d, Linear, MaxPool2d, ReLU, Sequential
from einops.layers.torch import EinMix, Rearrange, Reduce
return Sequential(
Conv2d(3, 6, kernel_size=(5, 5)),
Reduce("b c (h h2) (w w2) -> b c h w", "max", h2=2, w2=2) if use_reduce else MaxPool2d(kernel_size=2),
Conv2d(6, 16, kernel_size=(5, 5)),
Reduce("b c (h h2) (w w2) -> b c h w", "max", h2=2, w2=2),
torch.jit.script(Rearrange("b c h w -> b (c h w)"))
if add_scripted_layer
else Rearrange("b c h w -> b (c h w)"),
Linear(16 * 5 * 5, 120),
ReLU(),
Linear(120, 84),
ReLU(),
EinMix("b c1 -> (b c2)", weight_shape="c1 c2", bias_shape="c2", c1=84, c2=84),
EinMix("(b c2) -> b c3", weight_shape="c2 c3", bias_shape="c3", c2=84, c3=84),
Linear(84, 10),
)
def test_torch_layer():
if not is_backend_tested("torch"):
pytest.skip()
else:
# checked that torch present
import torch
import torch.jit
model1 = create_torch_model(use_reduce=True)
model2 = create_torch_model(use_reduce=False)
input = torch.randn([10, 3, 32, 32])
# random models have different predictions
assert not torch.allclose(model1(input), model2(input))
model2.load_state_dict(pickle.loads(pickle.dumps(model1.state_dict())))
assert torch.allclose(model1(input), model2(input))
# tracing (freezing)
model3 = torch.jit.trace(model2, example_inputs=input)
torch.testing.assert_close(model1(input), model3(input), atol=1e-3, rtol=1e-3)
torch.testing.assert_close(model1(input + 1), model3(input + 1), atol=1e-3, rtol=1e-3)
model4 = torch.jit.trace(model2, example_inputs=input)
torch.testing.assert_close(model1(input), model4(input), atol=1e-3, rtol=1e-3)
torch.testing.assert_close(model1(input + 1), model4(input + 1), atol=1e-3, rtol=1e-3)
def test_torch_layers_scripting():
if not is_backend_tested("torch"):
pytest.skip()
else:
import torch
for script_layer in [False, True]:
model1 = create_torch_model(use_reduce=True, add_scripted_layer=script_layer)
model2 = torch.jit.script(model1)
input = torch.randn([10, 3, 32, 32])
torch.testing.assert_close(model1(input), model2(input), atol=1e-3, rtol=1e-3)
def test_keras_layer():
rng = np.random.default_rng()
if not is_backend_tested("tensorflow"):
pytest.skip()
else:
import tensorflow as tf
if tf.__version__ < "2.16.":
# current implementation of layers follows new TF interface
pytest.skip()
from tensorflow.keras.layers import Conv2D as Conv2d
from tensorflow.keras.layers import Dense as Linear
from tensorflow.keras.layers import ReLU
from tensorflow.keras.models import Sequential
from einops.layers.keras import EinMix, Rearrange, Reduce, keras_custom_objects
def create_keras_model():
return Sequential(
[
Conv2d(6, kernel_size=5, input_shape=[32, 32, 3]),
Reduce("b c (h h2) (w w2) -> b c h w", "max", h2=2, w2=2),
Conv2d(16, kernel_size=5),
Reduce("b c (h h2) (w w2) -> b c h w", "max", h2=2, w2=2),
Rearrange("b c h w -> b (c h w)"),
Linear(120),
ReLU(),
Linear(84),
ReLU(),
EinMix("b c1 -> (b c2)", weight_shape="c1 c2", bias_shape="c2", c1=84, c2=84),
EinMix("(b c2) -> b c3", weight_shape="c2 c3", bias_shape="c3", c2=84, c3=84),
Linear(10),
]
)
model1 = create_keras_model()
model2 = create_keras_model()
input = rng.normal(size=[10, 32, 32, 3]).astype("float32")
# two randomly init models should provide different outputs
assert not np.allclose(model1.predict_on_batch(input), model2.predict_on_batch(input))
# get some temp filename
tmp_model_filename = "/tmp/einops_tf_model.h5"
# save arch + weights
print("temp_path_keras1", tmp_model_filename)
tf.keras.models.save_model(model1, tmp_model_filename)
model3 = tf.keras.models.load_model(tmp_model_filename, custom_objects=keras_custom_objects)
np.testing.assert_allclose(model1.predict_on_batch(input), model3.predict_on_batch(input))
weight_filename = "/tmp/einops_tf_model.weights.h5"
# save arch as json
model4 = tf.keras.models.model_from_json(model1.to_json(), custom_objects=keras_custom_objects)
model1.save_weights(weight_filename)
model4.load_weights(weight_filename)
model2.load_weights(weight_filename)
# check that differently-inialized model receives same weights
np.testing.assert_allclose(model1.predict_on_batch(input), model2.predict_on_batch(input))
# ulimate test
# save-load architecture, and then load weights - should return same result
np.testing.assert_allclose(model1.predict_on_batch(input), model4.predict_on_batch(input))
def test_flax_layers():
"""
One-off simple tests for Flax layers.
Unfortunately, Flax layers have a different interface from other layers.
"""
if not is_backend_tested("jax"):
pytest.skip()
else:
import flax
import jax
import jax.numpy as jnp
from flax import linen as nn
from einops.layers.flax import EinMix, Rearrange, Reduce
class NN(nn.Module):
@nn.compact
def __call__(self, x):
x = EinMix(
"b (h h2) (w w2) c -> b h w c_out", "h2 w2 c c_out", "c_out", sizes=dict(h2=2, w2=3, c=4, c_out=5)
)(x)
x = Rearrange("b h w c -> b (w h c)", sizes=dict(c=5))(x)
x = Reduce("b hwc -> b", "mean", dict(hwc=2 * 3 * 5))(x)
return x
model = NN()
fixed_input = jnp.ones([10, 2 * 2, 3 * 3, 4])
params = model.init(jax.random.PRNGKey(0), fixed_input)
def eval_at_point(params):
return jnp.linalg.norm(model.apply(params, fixed_input))
vandg = jax.value_and_grad(eval_at_point)
value0 = eval_at_point(params)
value1, grad1 = vandg(params)
assert jnp.allclose(value0, value1)
if jax.__version__ < "0.6.0":
tree_map = jax.tree_map
else:
tree_map = jax.tree.map
params2 = tree_map(lambda x1, x2: x1 - x2 * 0.001, params, grad1)
value2 = eval_at_point(params2)
assert value0 >= value2, (value0, value2)
# check serialization
fbytes = flax.serialization.to_bytes(params)
_loaded = flax.serialization.from_bytes(params, fbytes)
def test_einmix_decomposition():
"""
Testing that einmix correctly decomposes into smaller transformations.
"""
from einops.layers._einmix import _EinmixDebugger
mixin1 = _EinmixDebugger(
"a b c d e -> e d c b a",
weight_shape="d a b",
d=2, a=3, b=5,
) # fmt: off
assert mixin1.pre_reshape_pattern is None
assert mixin1.post_reshape_pattern is None
assert mixin1.einsum_pattern == "abcde,dab->edcba"
assert mixin1.saved_weight_shape == [2, 3, 5]
assert mixin1.saved_bias_shape is None
mixin2 = _EinmixDebugger(
"a b c d e -> e d c b a",
weight_shape="d a b",
bias_shape="a b c d e",
a=1, b=2, c=3, d=4, e=5,
) # fmt: off
assert mixin2.pre_reshape_pattern is None
assert mixin2.post_reshape_pattern is None
assert mixin2.einsum_pattern == "abcde,dab->edcba"
assert mixin2.saved_weight_shape == [4, 1, 2]
assert mixin2.saved_bias_shape == [5, 4, 3, 2, 1]
mixin3 = _EinmixDebugger(
"... -> ...",
weight_shape="",
bias_shape="",
) # fmt: off
assert mixin3.pre_reshape_pattern is None
assert mixin3.post_reshape_pattern is None
assert mixin3.einsum_pattern == "...,->..."
assert mixin3.saved_weight_shape == []
assert mixin3.saved_bias_shape == []
mixin4 = _EinmixDebugger(
"b a ... -> b c ...",
weight_shape="b a c",
a=1, b=2, c=3,
) # fmt: off
assert mixin4.pre_reshape_pattern is None
assert mixin4.post_reshape_pattern is None
assert mixin4.einsum_pattern == "ba...,bac->bc..."
assert mixin4.saved_weight_shape == [2, 1, 3]
assert mixin4.saved_bias_shape is None
mixin5 = _EinmixDebugger(
"(b a) ... -> b c (...)",
weight_shape="b a c",
a=1, b=2, c=3,
) # fmt: off
assert mixin5.pre_reshape_pattern == "(b a) ... -> b a ..."
assert mixin5.pre_reshape_lengths == dict(a=1, b=2)
assert mixin5.post_reshape_pattern == "b c ... -> b c (...)"
assert mixin5.einsum_pattern == "ba...,bac->bc..."
assert mixin5.saved_weight_shape == [2, 1, 3]
assert mixin5.saved_bias_shape is None
mixin6 = _EinmixDebugger(
"b ... (a c) -> b ... (a d)",
weight_shape="c d",
bias_shape="a d",
a=1, c=3, d=4,
) # fmt: off
assert mixin6.pre_reshape_pattern == "b ... (a c) -> b ... a c"
assert mixin6.pre_reshape_lengths == dict(a=1, c=3)
assert mixin6.post_reshape_pattern == "b ... a d -> b ... (a d)"
assert mixin6.einsum_pattern == "b...ac,cd->b...ad"
assert mixin6.saved_weight_shape == [3, 4]
assert mixin6.saved_bias_shape == [1, 1, 4] # (b) a d, ellipsis does not participate
mixin7 = _EinmixDebugger(
"a ... (b c) -> a (... d b)",
weight_shape="c d b",
bias_shape="d b",
b=2, c=3, d=4,
) # fmt: off
assert mixin7.pre_reshape_pattern == "a ... (b c) -> a ... b c"
assert mixin7.pre_reshape_lengths == dict(b=2, c=3)
assert mixin7.post_reshape_pattern == "a ... d b -> a (... d b)"
assert mixin7.einsum_pattern == "a...bc,cdb->a...db"
assert mixin7.saved_weight_shape == [3, 4, 2]
assert mixin7.saved_bias_shape == [1, 4, 2] # (a) d b, ellipsis does not participate
def test_einmix_restrictions():
"""
Testing different cases
"""
from einops.layers._einmix import _EinmixDebugger
with pytest.raises(EinopsError):
_EinmixDebugger(
"a b c d e -> e d c b a",
weight_shape="d a b",
d=2, a=3, # missing b
) # fmt: off
with pytest.raises(EinopsError):
_EinmixDebugger(
"a b c d e -> e d c b a",
weight_shape="w a b",
d=2, a=3, b=1 # missing d
) # fmt: off
with pytest.raises(EinopsError):
_EinmixDebugger(
"(...) a -> ... a",
weight_shape="a", a=1, # ellipsis on the left
) # fmt: off
with pytest.raises(EinopsError):
_EinmixDebugger(
"(...) a -> a ...",
weight_shape="a", a=1, # ellipsis on the right side after bias axis
bias_shape="a",
) # fmt: off
@@ -0,0 +1,658 @@
import itertools
import numpy as np
import pytest
from einops import EinopsError
from einops.einops import _enumerate_directions, rearrange, reduce, repeat
from einops.tests import FLOAT_REDUCTIONS as REDUCTIONS
from einops.tests import collect_test_backends, is_backend_tested
imp_op_backends = collect_test_backends(symbolic=False, layers=False)
sym_op_backends = collect_test_backends(symbolic=True, layers=False)
rng = np.random.default_rng()
identity_patterns = [
"...->...",
"a b c d e-> a b c d e",
"a b c d e ...-> ... a b c d e",
"a b c d e ...-> a ... b c d e",
"... a b c d e -> ... a b c d e",
"a ... e-> a ... e",
"a ... -> a ... ",
"a ... c d e -> a (...) c d e",
]
equivalent_rearrange_patterns = [
("a b c d e -> (a b) c d e", "a b ... -> (a b) ... "),
("a b c d e -> a b (c d) e", "... c d e -> ... (c d) e"),
("a b c d e -> a b c d e", "... -> ... "),
("a b c d e -> (a b c d e)", "... -> (...)"),
("a b c d e -> b (c d e) a", "a b ... -> b (...) a"),
("a b c d e -> b (a c d) e", "a b ... e -> b (a ...) e"),
]
equivalent_reduction_patterns = [
("a b c d e -> ", " ... -> "),
("a b c d e -> (e a)", "a ... e -> (e a)"),
("a b c d e -> d (a e)", " a b c d e ... -> d (a e) "),
("a b c d e -> (a b)", " ... c d e -> (...) "),
]
def test_collapsed_ellipsis_errors_out():
x = np.zeros([1, 1, 1, 1, 1])
rearrange(x, "a b c d ... -> a b c ... d")
with pytest.raises(EinopsError):
rearrange(x, "a b c d (...) -> a b c ... d")
rearrange(x, "... -> (...)")
with pytest.raises(EinopsError):
rearrange(x, "(...) -> (...)")
def test_ellipsis_ops_numpy():
x = np.arange(2 * 3 * 4 * 5 * 6).reshape([2, 3, 4, 5, 6])
for pattern in identity_patterns:
assert np.array_equal(x, rearrange(x, pattern)), pattern
for pattern1, pattern2 in equivalent_rearrange_patterns:
assert np.array_equal(rearrange(x, pattern1), rearrange(x, pattern2))
for reduction in ["min", "max", "sum"]:
for pattern1, pattern2 in equivalent_reduction_patterns:
assert np.array_equal(reduce(x, pattern1, reduction=reduction), reduce(x, pattern2, reduction=reduction))
# now just check coincidence with numpy
all_rearrange_patterns = [*identity_patterns]
for pattern_pairs in equivalent_rearrange_patterns:
all_rearrange_patterns.extend(pattern_pairs)
def check_op_against_numpy(backend, numpy_input, pattern, axes_lengths, reduction="rearrange", is_symbolic=False):
"""
Helper to test result of operation (rearrange or transpose) against numpy
if reduction == 'rearrange', rearrange op is tested, otherwise reduce
"""
def operation(x):
if reduction == "rearrange":
return rearrange(x, pattern, **axes_lengths)
else:
return reduce(x, pattern, reduction, **axes_lengths)
numpy_result = operation(numpy_input)
check_equal = np.array_equal
p_none_dimension = 0.5
if is_symbolic:
symbol_shape = [d if rng.random() >= p_none_dimension else None for d in numpy_input.shape]
symbol = backend.create_symbol(shape=symbol_shape)
result_symbol = operation(symbol)
backend_result = backend.eval_symbol(result_symbol, [(symbol, numpy_input)])
else:
backend_result = operation(backend.from_numpy(numpy_input))
backend_result = backend.to_numpy(backend_result)
check_equal(numpy_result, backend_result)
def test_ellipsis_ops_imperative():
"""Checking various patterns against numpy"""
x = np.arange(2 * 3 * 4 * 5 * 6).reshape([2, 3, 4, 5, 6])
for is_symbolic in [True, False]:
for backend in collect_test_backends(symbolic=is_symbolic, layers=False):
for pattern in identity_patterns + list(itertools.chain(*equivalent_rearrange_patterns)):
check_op_against_numpy(
backend, x, pattern, axes_lengths={}, reduction="rearrange", is_symbolic=is_symbolic
)
for reduction in ["min", "max", "sum"]:
for pattern in itertools.chain(*equivalent_reduction_patterns):
check_op_against_numpy(
backend, x, pattern, axes_lengths={}, reduction=reduction, is_symbolic=is_symbolic
)
def test_rearrange_array_api():
import numpy as xp
from einops import array_api as AA
if xp.__version__ < "2.0.0":
pytest.skip()
x = np.arange(2 * 3 * 4 * 5 * 6).reshape([2, 3, 4, 5, 6])
for pattern in identity_patterns + list(itertools.chain(*equivalent_rearrange_patterns)):
expected = rearrange(x, pattern)
result = AA.rearrange(xp.from_dlpack(x), pattern)
assert np.array_equal(AA.asnumpy(result + 0), expected)
def test_reduce_array_api():
import numpy as xp
from einops import array_api as AA
if xp.__version__ < "2.0.0":
pytest.skip()
x = np.arange(2 * 3 * 4 * 5 * 6).reshape([2, 3, 4, 5, 6])
for pattern in itertools.chain(*equivalent_reduction_patterns):
for reduction in ["min", "max", "sum"]:
expected = reduce(x, pattern, reduction=reduction)
result = AA.reduce(xp.from_dlpack(x), pattern, reduction=reduction)
assert np.array_equal(AA.asnumpy(np.asarray(result + 0)), expected)
def test_rearrange_consistency_numpy():
shape = [1, 2, 3, 5, 7, 11]
x = np.arange(np.prod(shape)).reshape(shape)
for pattern in [
"a b c d e f -> a b c d e f",
"b a c d e f -> a b d e f c",
"a b c d e f -> f e d c b a",
"a b c d e f -> (f e) d (c b a)",
"a b c d e f -> (f e d c b a)",
]:
result = rearrange(x, pattern)
assert len(np.setdiff1d(x, result)) == 0
assert result.dtype == x.dtype
result = rearrange(x, "a b c d e f -> a (b) (c d e) f")
assert np.array_equal(x.flatten(), result.flatten())
result = rearrange(x, "a aa aa1 a1a1 aaaa a11 -> a aa aa1 a1a1 aaaa a11")
assert np.array_equal(x, result)
result1 = rearrange(x, "a b c d e f -> f e d c b a")
result2 = rearrange(x, "f e d c b a -> a b c d e f")
assert np.array_equal(result1, result2)
result = rearrange(rearrange(x, "a b c d e f -> (f d) c (e b) a"), "(f d) c (e b) a -> a b c d e f", b=2, d=5)
assert np.array_equal(x, result)
sizes = dict(zip("abcdef", shape))
temp = rearrange(x, "a b c d e f -> (f d) c (e b) a", **sizes)
result = rearrange(temp, "(f d) c (e b) a -> a b c d e f", **sizes)
assert np.array_equal(x, result)
x2 = np.arange(2 * 3 * 4).reshape([2, 3, 4])
result = rearrange(x2, "a b c -> b c a")
assert x2[1, 2, 3] == result[2, 3, 1]
assert x2[0, 1, 2] == result[1, 2, 0]
def test_rearrange_permutations_numpy():
# tests random permutation of axes against two independent numpy ways
for n_axes in range(1, 10):
input = np.arange(2**n_axes).reshape([2] * n_axes)
permutation = rng.permutation(n_axes)
left_expression = " ".join("i" + str(axis) for axis in range(n_axes))
right_expression = " ".join("i" + str(axis) for axis in permutation)
expression = left_expression + " -> " + right_expression
result = rearrange(input, expression)
for pick in rng.integers(0, 2, [10, n_axes]):
assert input[tuple(pick)] == result[tuple(pick[permutation])]
for n_axes in range(1, 10):
input = np.arange(2**n_axes).reshape([2] * n_axes)
permutation = rng.permutation(n_axes)
left_expression = " ".join("i" + str(axis) for axis in range(n_axes)[::-1])
right_expression = " ".join("i" + str(axis) for axis in permutation[::-1])
expression = left_expression + " -> " + right_expression
result = rearrange(input, expression)
assert result.shape == input.shape
expected_result = np.zeros_like(input)
for original_axis, result_axis in enumerate(permutation):
expected_result |= ((input >> original_axis) & 1) << result_axis
assert np.array_equal(result, expected_result)
def test_reduction_imperatives():
for backend in imp_op_backends:
print("Reduction tests for ", backend.framework_name)
for reduction in REDUCTIONS:
# slight redundancy for simpler order - numpy version is evaluated multiple times
input = np.arange(2 * 3 * 4 * 5 * 6, dtype="int64").reshape([2, 3, 4, 5, 6])
if reduction in ["mean", "prod"]:
input = input / input.astype("float64").mean()
test_cases = [
["a b c d e -> ", {}, getattr(input, reduction)()],
["a ... -> ", {}, getattr(input, reduction)()],
["(a1 a2) ... (e1 e2) -> ", dict(a1=1, e2=2), getattr(input, reduction)()],
[
"a b c d e -> (e c) a",
{},
getattr(input, reduction)(axis=(1, 3)).transpose(2, 1, 0).reshape([-1, 2]),
],
[
"a ... c d e -> (e c) a",
{},
getattr(input, reduction)(axis=(1, 3)).transpose(2, 1, 0).reshape([-1, 2]),
],
[
"a b c d e ... -> (e c) a",
{},
getattr(input, reduction)(axis=(1, 3)).transpose(2, 1, 0).reshape([-1, 2]),
],
["a b c d e -> (e c a)", {}, getattr(input, reduction)(axis=(1, 3)).transpose(2, 1, 0).reshape([-1])],
["(a a2) ... -> (a2 a) ...", dict(a2=1), input],
]
for pattern, axes_lengths, expected_result in test_cases:
result = reduce(backend.from_numpy(input.copy()), pattern, reduction=reduction, **axes_lengths)
result = backend.to_numpy(result)
assert np.allclose(result, expected_result), f"Failed at {pattern}"
def test_reduction_symbolic():
for backend in sym_op_backends:
print("Reduction tests for ", backend.framework_name)
for reduction in REDUCTIONS:
input = np.arange(2 * 3 * 4 * 5 * 6, dtype="int64").reshape([2, 3, 4, 5, 6])
input = input / input.astype("float64").mean()
# slight redundancy for simpler order - numpy version is evaluated multiple times
test_cases = [
["a b c d e -> ", {}, getattr(input, reduction)()],
["a ... -> ", {}, getattr(input, reduction)()],
["(a a2) ... (e e2) -> ", dict(a2=1, e2=1), getattr(input, reduction)()],
[
"a b c d e -> (e c) a",
{},
getattr(input, reduction)(axis=(1, 3)).transpose(2, 1, 0).reshape([-1, 2]),
],
[
"a ... c d e -> (e c) a",
{},
getattr(input, reduction)(axis=(1, 3)).transpose(2, 1, 0).reshape([-1, 2]),
],
[
"a b c d e ... -> (e c) a",
{},
getattr(input, reduction)(axis=(1, 3)).transpose(2, 1, 0).reshape([-1, 2]),
],
["a b c d e -> (e c a)", {}, getattr(input, reduction)(axis=(1, 3)).transpose(2, 1, 0).reshape([-1])],
["(a a2) ... -> (a2 a) ...", dict(a2=1), input],
]
for pattern, axes_lengths, expected_numpy_result in test_cases:
shapes = [input.shape, [None for _ in input.shape]]
for shape in shapes:
sym = backend.create_symbol(shape)
result_sym = reduce(sym, pattern, reduction=reduction, **axes_lengths)
result = backend.eval_symbol(result_sym, [(sym, input)])
assert np.allclose(result, expected_numpy_result)
if True:
shape = []
_axes_lengths = {**axes_lengths}
for axis, length in zip("abcde", input.shape):
# filling as much as possible with Nones
if axis in pattern:
shape.append(None)
_axes_lengths[axis] = length
else:
shape.append(length)
sym = backend.create_symbol(shape)
result_sym = reduce(sym, pattern, reduction=reduction, **_axes_lengths)
result = backend.eval_symbol(result_sym, [(sym, input)])
assert np.allclose(result, expected_numpy_result)
def test_reduction_stress_imperatives():
for backend in imp_op_backends:
print("Stress-testing reduction for ", backend.framework_name)
for reduction in [*REDUCTIONS, "rearrange"]:
dtype = "int64"
coincide = np.array_equal
if reduction in ["mean", "prod"]:
dtype = "float64"
coincide = np.allclose
max_dim = 11
if "oneflow" in backend.framework_name:
max_dim = 7
if "paddle" in backend.framework_name:
max_dim = 9
for n_axes in range(max_dim):
shape = rng.integers(2, 4, size=n_axes)
permutation = rng.permutation(n_axes)
skipped = 0 if reduction == "rearrange" else rng.integers(n_axes + 1)
left = " ".join("x" + str(i) for i in range(n_axes))
right = " ".join("x" + str(i) for i in permutation[skipped:])
pattern = left + "->" + right
x = np.arange(1, 1 + np.prod(shape), dtype=dtype).reshape(shape)
if reduction == "prod":
x /= x.mean() # to avoid overflows
result1 = reduce(x, pattern, reduction=reduction)
result2 = x.transpose(permutation)
if skipped > 0:
result2 = getattr(result2, reduction)(axis=tuple(range(skipped)))
assert coincide(result1, result2)
check_op_against_numpy(backend, x, pattern, reduction=reduction, axes_lengths={}, is_symbolic=False)
def test_reduction_with_callable_imperatives():
x_numpy = np.arange(2 * 3 * 4 * 5 * 6).reshape([2, 3, 4, 5, 6]).astype("float32")
x_numpy /= x_numpy.max()
def logsumexp_torch(x, tuple_of_axes):
return x.logsumexp(tuple_of_axes)
def logsumexp_tf(x, tuple_of_axes):
import tensorflow as tf
return tf.reduce_logsumexp(x, tuple_of_axes)
def logsumexp_keras(x, tuple_of_axes):
import tensorflow.keras.backend as k
return k.logsumexp(x, tuple_of_axes)
def logsumexp_numpy(x, tuple_of_axes):
# very naive logsumexp to compare to
minused = x.max(tuple_of_axes)
y = x - x.max(tuple_of_axes, keepdims=True)
y = np.exp(y)
y = np.sum(y, axis=tuple_of_axes)
return np.log(y) + minused
from einops._backends import NumpyBackend, TensorflowBackend, TFKerasBackend, TorchBackend
backend2callback = {
TorchBackend.framework_name: logsumexp_torch,
TensorflowBackend.framework_name: logsumexp_tf,
TFKerasBackend.framework_name: logsumexp_keras,
NumpyBackend.framework_name: logsumexp_numpy,
}
for backend in imp_op_backends:
if backend.framework_name not in backend2callback:
continue
backend_callback = backend2callback[backend.framework_name]
x_backend = backend.from_numpy(x_numpy)
for pattern1, pattern2 in equivalent_reduction_patterns:
print("Test reduction with callable for ", backend.framework_name, pattern1, pattern2)
output_numpy = reduce(x_numpy, pattern1, reduction=logsumexp_numpy)
output_backend = reduce(x_backend, pattern1, reduction=backend_callback)
assert np.allclose(
output_numpy,
backend.to_numpy(output_backend),
)
def test_enumerating_directions():
for backend in imp_op_backends:
print("testing directions for", backend.framework_name)
for shape in [[], [1], [1, 1, 1], [2, 3, 5, 7]]:
x = np.arange(np.prod(shape)).reshape(shape)
axes1 = _enumerate_directions(x)
axes2 = _enumerate_directions(backend.from_numpy(x))
assert len(axes1) == len(axes2) == len(shape)
axes2 = [backend.to_numpy(ax) for ax in axes2]
for ax1, ax2 in zip(axes1, axes2):
assert ax1.shape == ax2.shape
assert np.allclose(ax1, ax2)
def test_concatenations_and_stacking():
for backend in imp_op_backends:
print("testing shapes for ", backend.framework_name)
for n_arrays in [1, 2, 5]:
shapes = [[], [1], [1, 1], [2, 3, 5, 7], [1] * 6]
for shape in shapes:
arrays1 = [np.arange(i, i + np.prod(shape)).reshape(shape) for i in range(n_arrays)]
arrays2 = [backend.from_numpy(array) for array in arrays1]
result0 = np.asarray(arrays1)
result1 = rearrange(arrays1, "...->...")
result2 = rearrange(arrays2, "...->...")
assert np.array_equal(result0, result1)
assert np.array_equal(result1, backend.to_numpy(result2))
result1 = rearrange(arrays1, "b ... -> ... b")
result2 = rearrange(arrays2, "b ... -> ... b")
assert np.array_equal(result1, backend.to_numpy(result2))
def test_gradients_imperatives():
# lazy - just checking reductions
for reduction in REDUCTIONS:
if reduction in ("any", "all"):
continue # non-differentiable ops
x = np.arange(1, 1 + 2 * 3 * 4).reshape([2, 3, 4]).astype("float32")
results = {}
for backend in imp_op_backends:
y0 = backend.from_numpy(x)
if not hasattr(y0, "grad"):
continue
y1 = reduce(y0, "a b c -> c a", reduction=reduction)
y2 = reduce(y1, "c a -> a c", reduction=reduction)
y3 = reduce(y2, "a (c1 c2) -> a", reduction=reduction, c1=2)
y4 = reduce(y3, "... -> ", reduction=reduction)
y4.backward()
grad = backend.to_numpy(y0.grad)
results[backend.framework_name] = grad
print("comparing gradients for", results.keys())
for name1, grad1 in results.items():
for name2, grad2 in results.items():
assert np.allclose(grad1, grad2), [name1, name2, "provided different gradients"]
def test_tiling_imperatives():
for backend in imp_op_backends:
print("Tiling tests for ", backend.framework_name)
input = np.arange(2 * 3 * 5, dtype="int64").reshape([2, 1, 3, 1, 5])
test_cases = [
(1, 1, 1, 1, 1),
(1, 2, 1, 3, 1),
(3, 1, 1, 4, 1),
]
for repeats in test_cases:
expected = np.tile(input, repeats)
converted = backend.from_numpy(input)
repeated = backend.tile(converted, repeats)
result = backend.to_numpy(repeated)
assert np.array_equal(result, expected)
def test_tiling_symbolic():
for backend in sym_op_backends:
print("Tiling tests for ", backend.framework_name)
input = np.arange(2 * 3 * 5, dtype="int64").reshape([2, 1, 3, 1, 5])
test_cases = [
(1, 1, 1, 1, 1),
(1, 2, 1, 3, 1),
(3, 1, 1, 4, 1),
]
for repeats in test_cases:
expected = np.tile(input, repeats)
sym = backend.create_symbol(input.shape)
result = backend.eval_symbol(backend.tile(sym, repeats), [[sym, input]])
assert np.array_equal(result, expected)
sym = backend.create_symbol([None] * len(input.shape))
result = backend.eval_symbol(backend.tile(sym, repeats), [[sym, input]])
assert np.array_equal(result, expected)
repeat_test_cases = [
# all assume that input has shape [2, 3, 5]
("a b c -> c a b", dict()),
("a b c -> (c copy a b)", dict(copy=2, a=2, b=3, c=5)),
("a b c -> (a copy) b c ", dict(copy=1)),
("a b c -> (c a) (copy1 b copy2)", dict(a=2, copy1=1, copy2=2)),
("a ... -> a ... copy", dict(copy=4)),
("... c -> ... (copy1 c copy2)", dict(copy1=1, copy2=2)),
("... -> ... ", dict()),
(" ... -> copy1 ... copy2 ", dict(copy1=2, copy2=3)),
("a b c -> copy1 a copy2 b c () ", dict(copy1=2, copy2=1)),
]
def check_reversion(x, repeat_pattern, **sizes):
"""Checks repeat pattern by running reduction"""
left, right = repeat_pattern.split("->")
reduce_pattern = right + "->" + left
repeated = repeat(x, repeat_pattern, **sizes)
reduced_min = reduce(repeated, reduce_pattern, reduction="min", **sizes)
reduced_max = reduce(repeated, reduce_pattern, reduction="max", **sizes)
assert np.array_equal(x, reduced_min)
assert np.array_equal(x, reduced_max)
def test_repeat_numpy():
# check repeat vs reduce. Repeat works ok if reverse reduction with min and max work well
x = np.arange(2 * 3 * 5).reshape([2, 3, 5])
x1 = repeat(x, "a b c -> copy a b c ", copy=1)
assert np.array_equal(x[None], x1)
for pattern, axis_dimensions in repeat_test_cases:
check_reversion(x, pattern, **axis_dimensions)
def test_repeat_imperatives():
x = np.arange(2 * 3 * 5).reshape([2, 3, 5])
for backend in imp_op_backends:
print("Repeat tests for ", backend.framework_name)
for pattern, axis_dimensions in repeat_test_cases:
expected = repeat(x, pattern, **axis_dimensions)
converted = backend.from_numpy(x)
repeated = repeat(converted, pattern, **axis_dimensions)
result = backend.to_numpy(repeated)
assert np.array_equal(result, expected)
def test_repeat_symbolic():
x = np.arange(2 * 3 * 5).reshape([2, 3, 5])
for backend in sym_op_backends:
print("Repeat tests for ", backend.framework_name)
for pattern, axis_dimensions in repeat_test_cases:
expected = repeat(x, pattern, **axis_dimensions)
sym = backend.create_symbol(x.shape)
result = backend.eval_symbol(repeat(sym, pattern, **axis_dimensions), [[sym, x]])
assert np.array_equal(result, expected)
def test_repeat_array_api():
import numpy as xp
from einops import array_api as AA
if xp.__version__ < "2.0.0":
pytest.skip()
x = np.arange(2 * 3 * 5).reshape([2, 3, 5])
for pattern, axis_dimensions in repeat_test_cases:
expected = repeat(x, pattern, **axis_dimensions)
result = AA.repeat(xp.from_dlpack(x), pattern, **axis_dimensions)
assert np.array_equal(AA.asnumpy(result + 0), expected)
test_cases_repeat_anonymous = [
# all assume that input has shape [1, 2, 4, 6]
("a b c d -> c a d b", dict()),
("a b c d -> (c 2 d a b)", dict(a=1, c=4, d=6)),
("1 b c d -> (d copy 1) 3 b c ", dict(copy=3)),
("1 ... -> 3 ... ", dict()),
("() ... d -> 1 (copy1 d copy2) ... ", dict(copy1=2, copy2=3)),
("1 b c d -> (1 1) (1 b) 2 c 3 d (1 1)", dict()),
]
def test_anonymous_axes():
x = np.arange(1 * 2 * 4 * 6).reshape([1, 2, 4, 6])
for pattern, axis_dimensions in test_cases_repeat_anonymous:
check_reversion(x, pattern, **axis_dimensions)
def test_list_inputs():
x = np.arange(2 * 3 * 4 * 5 * 6).reshape([2, 3, 4, 5, 6])
assert np.array_equal(
rearrange(list(x), "... -> (...)"),
rearrange(x, "... -> (...)"),
)
assert np.array_equal(
reduce(list(x), "a ... e -> (...)", "min"),
reduce(x, "a ... e -> (...)", "min"),
)
assert np.array_equal(
repeat(list(x), "... -> b (...)", b=3),
repeat(x, "... -> b (...)", b=3),
)
def test_torch_compile_with_dynamic_shape():
if not is_backend_tested("torch"):
pytest.skip()
import torch
# somewhat reasonable debug messages
torch._dynamo.config.verbose = True
def func1(x):
# test contains ellipsis
a, b, c, *other = x.shape
x = rearrange(x, "(a a2) b c ... -> b (c a2) (a ...)", a2=2)
# test contains passing expression as axis length
x = reduce(x, "b ca2 A -> b A", "sum", ca2=c * 2)
return x
# seems can't test static and dynamic in the same test run.
func1_compiled_static = torch.compile(func1, dynamic=False, fullgraph=True)
func1_compiled_dynamic = torch.compile(func1, dynamic=True, fullgraph=True)
x = torch.randn(size=[4, 5, 6, 3])
assert torch.allclose(func1_compiled_static(x), func1(x), atol=1e-5)
assert torch.allclose(func1_compiled_dynamic(x), func1(x), atol=1e-5)
# check with input of different dimensionality, and with all shape elements changed
x = torch.randn(size=[6, 3, 4, 2, 3])
assert torch.allclose(func1_compiled_static(x), func1(x), atol=1e-5)
assert torch.allclose(func1_compiled_dynamic(x), func1(x), atol=1e-5)
def bit_count(x):
return sum((x >> i) & 1 for i in range(20))
def test_reduction_imperatives_booleans():
"""Checks that any/all reduction works in all frameworks"""
x_np = np.asarray([(bit_count(x) % 2) == 0 for x in range(2**6)]).reshape([2] * 6)
for backend in imp_op_backends:
print("Reduction any/all tests for ", backend.framework_name)
for axis in range(6):
expected_result_any = np.any(x_np, axis=axis, keepdims=True)
expected_result_all = np.all(x_np, axis=axis, keepdims=True)
assert not np.array_equal(expected_result_any, expected_result_all)
axes = list("abcdef")
axes_in = list(axes)
axes_out = list(axes)
axes_out[axis] = "1"
pattern = (" ".join(axes_in)) + " -> " + (" ".join(axes_out))
res_any = reduce(backend.from_numpy(x_np), pattern, reduction="any")
res_all = reduce(backend.from_numpy(x_np), pattern, reduction="all")
assert np.array_equal(expected_result_any, backend.to_numpy(res_any))
assert np.array_equal(expected_result_all, backend.to_numpy(res_all))
# expected result: any/all
expected_result_any = np.any(x_np, axis=(0, 1), keepdims=True)
expected_result_all = np.all(x_np, axis=(0, 1), keepdims=True)
pattern = "a b ... -> 1 1 ..."
res_any = reduce(backend.from_numpy(x_np), pattern, reduction="any")
res_all = reduce(backend.from_numpy(x_np), pattern, reduction="all")
assert np.array_equal(expected_result_any, backend.to_numpy(res_any))
assert np.array_equal(expected_result_all, backend.to_numpy(res_all))
@@ -0,0 +1,363 @@
import subprocess
import tempfile
from doctest import testmod
from pathlib import Path
import numpy as np
import pytest
import einops
import einops.layers
from einops._backends import AbstractBackend
from einops.einops import _optimize_transformation, parse_shape, rearrange
from einops.tests import collect_test_backends, is_backend_tested
__author__ = "Alex Rogozhnikov"
rng = np.random.default_rng()
def test_doctests_examples():
# tests docstrings, additionally
testmod(einops.layers, raise_on_error=True, extraglobs=dict(np=np))
testmod(einops.einops, raise_on_error=True, extraglobs=dict(np=np))
def test_backends_installed():
"""
This test will fail if some of backends are not installed or can't be imported
Other tests will just work and only test installed backends.
"""
from . import parse_backends_to_test
backends_to_test = set(parse_backends_to_test())
errors = []
# Find backend subclasses recursively
backend_subclasses = []
backends = AbstractBackend.__subclasses__()
while backends:
backend = backends.pop()
backends += backend.__subclasses__()
backend_subclasses.append(backend)
for backend_type in backend_subclasses:
if backend_type.framework_name not in backends_to_test:
continue
try:
# instantiate
backend_type()
backends_to_test.remove(backend_type.framework_name)
except Exception as e:
errors.append((backend_type.framework_name, e))
assert len(errors) == 0, errors
assert len(backends_to_test) == 0, f"did not instantiate {backends_to_test=}, they won't be tested"
def test_optimize_transformations_numpy():
print("Testing optimizations")
shapes = [[2] * n_dimensions for n_dimensions in range(14)]
shapes += [[3] * n_dimensions for n_dimensions in range(6)]
shapes += [[2, 3, 5, 7]]
shapes += [[2, 3, 5, 7, 11, 17]]
for shape in shapes:
for _attempt in range(5):
n_dimensions = len(shape)
x = rng.integers(0, 2**12, size=shape).reshape([-1])
init_shape = shape[:]
n_reduced = rng.integers(0, n_dimensions + 1)
reduced_axes = tuple(rng.permutation(n_dimensions)[:n_reduced])
axes_reordering = rng.permutation(n_dimensions - n_reduced)
final_shape = rng.integers(0, 1024, size=333) # just random
init_shape2, reduced_axes2, axes_reordering2, final_shape2 = combination2 = _optimize_transformation(
init_shape, reduced_axes, axes_reordering, final_shape
)
assert np.array_equal(final_shape, final_shape2)
result1 = x.reshape(init_shape).sum(axis=reduced_axes).transpose(axes_reordering).reshape([-1])
result2 = x.reshape(init_shape2).sum(axis=reduced_axes2).transpose(axes_reordering2).reshape([-1])
assert np.array_equal(result1, result2)
# testing we can't optimize this formula again
combination3 = _optimize_transformation(*combination2)
for a, b in zip(combination2, combination3):
assert np.array_equal(a, b)
_IMPERATIVE_BACKENDS = collect_test_backends(symbolic=False, layers=False)
x_np = np.zeros([10, 20, 30, 40])
def test_parse_shape_imperative():
for backend in _IMPERATIVE_BACKENDS:
print("Shape parsing for ", backend.framework_name)
parsed1 = parse_shape(x_np, "a b c d")
parsed2 = parse_shape(backend.from_numpy(x_np), "a b c d")
assert parsed1 == parsed2 == dict(a=10, b=20, c=30, d=40)
assert parsed1 != dict(a=1, b=20, c=30, d=40) != parsed2
def test_underscore():
for backend in _IMPERATIVE_BACKENDS:
parsed1 = parse_shape(x_np, "_ _ _ _")
parsed2 = parse_shape(backend.from_numpy(x_np), "_ _ _ _")
assert parsed1 == parsed2 == dict()
def test_underscore_one():
for backend in _IMPERATIVE_BACKENDS:
parsed1 = parse_shape(x_np, "_ _ _ hello")
parsed2 = parse_shape(backend.from_numpy(x_np), "_ _ _ hello")
assert parsed1 == parsed2 == dict(hello=40)
def test_underscore_several():
for backend in _IMPERATIVE_BACKENDS:
parsed1 = parse_shape(x_np, "_ _ a1 a1a111a")
parsed2 = parse_shape(backend.from_numpy(x_np), "_ _ a1 a1a111a")
assert parsed1 == parsed2 == dict(a1=30, a1a111a=40)
def test_repeating():
with pytest.raises(einops.EinopsError):
parse_shape(x_np, "a a b b")
for backend in _IMPERATIVE_BACKENDS:
with pytest.raises(einops.EinopsError):
parse_shape(backend.from_numpy(x_np), "a a b b")
def test_ellipsis():
for backend in _IMPERATIVE_BACKENDS:
for shape, pattern, expected in [
([10, 20], "...", dict()),
([10], "... a", dict(a=10)),
([10, 20], "... a", dict(a=20)),
([10, 20, 30], "... a", dict(a=30)),
([10, 20, 30, 40], "... a", dict(a=40)),
([10], "a ...", dict(a=10)),
([10, 20], "a ...", dict(a=10)),
([10, 20, 30], "a ...", dict(a=10)),
([10, 20, 30, 40], "a ...", dict(a=10)),
([10, 20, 30, 40], " a ... b", dict(a=10, b=40)),
([10, 40], " a ... b", dict(a=10, b=40)),
]:
x = np.ones(shape)
parsed1 = parse_shape(x, pattern)
parsed2 = parse_shape(backend.from_numpy(x), pattern)
assert parsed1 == parsed2 == expected
def test_parse_with_anonymous_axes():
for backend in _IMPERATIVE_BACKENDS:
for shape, pattern, expected in [
([1, 2, 3, 4], "1 2 3 a", dict(a=4)),
([10, 1, 2], "a 1 2", dict(a=10)),
([10, 1, 2], "a () 2", dict(a=10)),
]:
x = np.ones(shape)
parsed1 = parse_shape(x, pattern)
parsed2 = parse_shape(backend.from_numpy(x), pattern)
assert parsed1 == parsed2 == expected
def test_failures():
for backend in _IMPERATIVE_BACKENDS:
# every test should fail
for shape, pattern in [
([1, 2, 3, 4], "a b c"),
([1, 2, 3, 4], "2 a b c"),
([1, 2, 3, 4], "a b c ()"),
([1, 2, 3, 4], "a b c d e"),
([1, 2, 3, 4], "a b c d e ..."),
([1, 2, 3, 4], "a b c ()"),
]:
with pytest.raises(RuntimeError):
x = np.ones(shape)
parse_shape(backend.from_numpy(x), pattern)
_SYMBOLIC_BACKENDS = [
*collect_test_backends(symbolic=True, layers=False),
*collect_test_backends(symbolic=True, layers=True),
]
# tensorflow.keras needs special way to compile,
# shape vars can be used only inside layers but not as outputs
_SYMBOLIC_BACKENDS = [backend for backend in _SYMBOLIC_BACKENDS if backend.framework_name != "tensorflow.keras"]
@pytest.mark.parametrize("backend", _SYMBOLIC_BACKENDS)
def test_parse_shape_symbolic(backend):
for input_shape in [
[10, 20, 30, 40],
[10, 20, None, None],
[None, None, None, None],
]:
print(f"special shape parsing {backend.framework_name=} {input_shape=}")
input_symbol = backend.create_symbol(input_shape)
shape_placeholder = parse_shape(input_symbol, "a b c d")
out_shape = {}
for name, symbol in shape_placeholder.items():
out_shape[name] = (
symbol
if isinstance(symbol, int)
else backend.eval_symbol(symbol, [(input_symbol, np.zeros([10, 20, 30, 40]))])
) # out shape element is either int, or symbol that we are able to eval
print(out_shape)
result_placeholder = rearrange(
input_symbol, "a b (c1 c2) (d1 d2) -> (a b d1) c1 (c2 d2)", **parse_shape(input_symbol, "a b c1 _"), d2=2
)
result = backend.eval_symbol(result_placeholder, [(input_symbol, np.zeros([10, 20, 30, 40]))])
print(result.shape)
assert result.shape == (10 * 20 * 20, 30, 1 * 2)
assert np.allclose(result, 0)
@pytest.mark.parametrize("backend", _SYMBOLIC_BACKENDS)
def test_parse_shape_symbolic_ellipsis(backend):
for static_shape, shape, pattern, expected in [
([10, 20], [None, None], "...", dict()),
([10], [None], "... a", dict(a=10)),
([10, 20], [None, None], "... a", dict(a=20)),
([10, 20, 30], [None, None, None], "... a", dict(a=30)),
([10, 20, 30, 40], [None, None, None, None], "... a", dict(a=40)),
([10], [None], "a ...", dict(a=10)),
([10, 20], [None, None], "a ...", dict(a=10)),
([10, 20, 30], [None, None, None], "a ...", dict(a=10)),
([10, 20, 30, 40], [None, None, None, None], "a ...", dict(a=10)),
([10, 20, 30, 40], [None, None, None, None], " a ... b", dict(a=10, b=40)),
([10, 40], [None, None], " a ... b ", dict(a=10, b=40)),
]:
input_symbol = backend.create_symbol(shape)
shape_placeholder = parse_shape(input_symbol, pattern)
out_shape = {}
for name, symbol in shape_placeholder.items():
if isinstance(symbol, int):
out_shape[name] = symbol
else:
out_shape[name] = backend.eval_symbol(symbol, [(input_symbol, np.zeros(static_shape))])
assert out_shape == expected
def test_is_float_type():
backends = collect_test_backends(symbolic=False, layers=False)
backends += collect_test_backends(symbolic=False, layers=True)
for backend in backends:
for dtype in ["int32", "int64", "float32", "float64"]:
is_float = "float" in dtype
input = np.zeros([3, 4, 5], dtype=dtype)
input = backend.from_numpy(input)
assert backend.is_float_type(input) == is_float, (dtype, backend, input.dtype)
def test_torch_compile_for_functions():
"""
Test ensures that allow_ops_in_compiled_graph allows compiling in a single graph
Additionally we ensure that after compilation cache works properly
(by changing shapes and patterns)
We additionally check that pack/unpack still can be handled
despite variable number of inputs/outputs
"""
if not is_backend_tested("torch"):
pytest.skip()
import torch
from torch import nn
from einops import einsum, pack, reduce, repeat, unpack
from einops._torch_specific import allow_ops_in_compiled_graph
allow_ops_in_compiled_graph()
class TorchModuleWithOperations(nn.Module):
def __init__(self) -> None:
super().__init__()
def forward(self, x_abc, suffix=""):
a, b, c = x_abc.shape
def suf(pattern):
parts = pattern.split()
return " ".join([p if p[-1] not in "acd" else p + suffix for p in parts])
# patterns look a bit strange because names a, c, d will be modified on every run
# by suf function
x_abcd = repeat(x_abc, suf("a b c -> a b c 4"))
x_abc = reduce(x_abcd, suf("a b c d -> a b c"), "min")
x_abdc, ps = pack([x_abc] * (2 + len(suffix)), suf("a b * c"))
x_array = unpack(rearrange(x_abdc, suf("a b d c -> (a b ) 1 c d")), ps, "ab one1 c *")
x1 = x_array[0] + len(x_array)
x1 = rearrange(x1, suf("(a b ) 1 c -> a b c"), b=b)
addition = einsum(x_abc, x_abcd, suf("a b c , a b c d -> d"))[0]
return x1 + addition
original = TorchModuleWithOperations()
compiled = torch.compile(original, fullgraph=True)
for size in [10, 20, 40]:
x = torch.rand([size, size + 1, size + 2])
for suffix in ["", "suf1", "other_suffix"]:
result1 = compiled(x, suffix)
result2 = original(x.double(), suffix).float()
torch.testing.assert_close(result1, result2, atol=1e-5, rtol=1e-5)
def test_torch_compile_for_layers():
"""
Einops layers are in general very friendly towards tracing/compiling,
but we still want to make sure we can compile them.
"""
if not is_backend_tested("torch"):
pytest.skip()
import torch
from torch import nn
from einops.layers.torch import EinMix, Rearrange, Reduce
original = nn.Sequential(
Rearrange("b (t c) -> b t c", c=16),
EinMix("b t c -> qkv b t cout", weight_shape="qkv c cout", bias_shape="qkv cout", qkv=3, c=16, cout=8),
Reduce("qkv b t cout -> b t qkv", "min", cout=8),
)
compiled = torch.compile(original, fullgraph=True)
for size in [16, 32, 64]:
x = torch.rand([size, size])
result1 = original(x)
result2 = compiled(x)
assert torch.allclose(result1, result2)
src = """
import einops
import numpy as np
from concurrent.futures import ThreadPoolExecutor
import torch
def f():
return einops.rearrange(np.ndarray((20, 150, 150)), "... i j -> ... j i")
with ThreadPoolExecutor(max_workers=2) as ex:
fs = []
for i in range(20):
fs.append(ex.submit(f))
for fut in fs:
fut.result()
"""
def test_einops_threading():
# requires both. Reproduces problem from https://github.com/arogozhnikov/einops/issues/391
if not is_backend_tested("torch"):
pytest.skip()
if not is_backend_tested("numpy"):
pytest.skip()
with tempfile.TemporaryDirectory() as d:
testfile = Path(d).joinpath("test.py")
testfile.write_text(src)
subprocess.run(["python", testfile.absolute().as_posix()], check=True)
@@ -0,0 +1,312 @@
import dataclasses
import typing
import numpy as np
import pytest
from einops import EinopsError, asnumpy, pack, unpack
from einops.tests import collect_test_backends
rng = np.random.default_rng()
def pack_unpack(xs, pattern):
x, ps = pack(xs, pattern)
unpacked = unpack(xs, ps, pattern)
assert len(unpacked) == len(xs)
for a, b in zip(unpacked, xs):
assert np.allclose(asnumpy(a), asnumpy(b))
def unpack_and_pack(x, ps, pattern: str):
unpacked = unpack(x, ps, pattern)
packed, ps2 = pack(unpacked, pattern=pattern)
assert np.allclose(asnumpy(packed), asnumpy(x))
return unpacked
def unpack_and_pack_against_numpy(x, ps, pattern: str):
capturer_backend = CaptureException()
capturer_numpy = CaptureException()
with capturer_backend:
unpacked = unpack(x, ps, pattern)
packed, ps2 = pack(unpacked, pattern=pattern)
with capturer_numpy:
x_np = asnumpy(x)
unpacked_np = unpack(x_np, ps, pattern)
packed_np, ps3 = pack(unpacked_np, pattern=pattern)
assert type(capturer_numpy.exception) == type(capturer_backend.exception) # noqa E721
if capturer_numpy.exception is not None:
# both failed
return
else:
# neither failed, check results are identical
assert np.allclose(asnumpy(packed), asnumpy(x))
assert np.allclose(asnumpy(packed_np), asnumpy(x))
assert len(unpacked) == len(unpacked_np)
for a, b in zip(unpacked, unpacked_np):
assert np.allclose(asnumpy(a), b)
class CaptureException:
def __enter__(self):
self.exception = None
def __exit__(self, exc_type, exc_val, exc_tb):
self.exception = exc_val
return True
def test_numpy_trivial(H=13, W=17):
def rand(*shape):
return rng.random(shape)
def check(a, b):
assert a.dtype == b.dtype
assert a.shape == b.shape
assert np.all(a == b)
r, g, b = rand(3, H, W)
embeddings = rand(H, W, 32)
check(
np.stack([r, g, b], axis=2),
pack([r, g, b], "h w *")[0],
)
check(
np.stack([r, g, b], axis=1),
pack([r, g, b], "h * w")[0],
)
check(
np.stack([r, g, b], axis=0),
pack([r, g, b], "* h w")[0],
)
check(
np.concatenate([r, g, b], axis=1),
pack([r, g, b], "h *")[0],
)
check(
np.concatenate([r, g, b], axis=0),
pack([r, g, b], "* w")[0],
)
i = np.index_exp[:, :, None]
check(
np.concatenate([r[i], g[i], b[i], embeddings], axis=2),
pack([r, g, b, embeddings], "h w *")[0],
)
with pytest.raises(EinopsError):
pack([r, g, b, embeddings], "h w nonexisting_axis *")
pack([r, g, b], "some_name_for_H some_name_for_w1 *")
with pytest.raises(EinopsError):
pack([r, g, b, embeddings], "h _w *") # no leading underscore
with pytest.raises(EinopsError):
pack([r, g, b, embeddings], "h_ w *") # no trailing underscore
with pytest.raises(EinopsError):
pack([r, g, b, embeddings], "1h_ w *")
with pytest.raises(EinopsError):
pack([r, g, b, embeddings], "1 w *")
with pytest.raises(EinopsError):
pack([r, g, b, embeddings], "h h *")
# capital and non-capital are different
pack([r, g, b, embeddings], "h H *")
@dataclasses.dataclass
class UnpackTestCase:
shape: typing.Tuple[int, ...]
pattern: str
def dim(self):
return self.pattern.split().index("*")
def selfcheck(self):
assert self.shape[self.dim()] == 5
cases = [
# NB: in all cases unpacked axis is of length 5.
# that's actively used in tests below
UnpackTestCase((5,), "*"),
UnpackTestCase((5, 7), "* seven"),
UnpackTestCase((7, 5), "seven *"),
UnpackTestCase((5, 3, 4), "* three four"),
UnpackTestCase((4, 5, 3), "four * three"),
UnpackTestCase((3, 4, 5), "three four *"),
]
def test_pack_unpack_with_numpy():
case: UnpackTestCase
for case in cases:
shape = case.shape
pattern = case.pattern
x = rng.random(shape)
# all correct, no minus 1
unpack_and_pack(x, [[2], [1], [2]], pattern)
# no -1, asking for wrong shapes
with pytest.raises(EinopsError):
unpack_and_pack(x, [[2], [1], [2]], pattern + " non_existent_axis")
with pytest.raises(EinopsError):
unpack_and_pack(x, [[2], [1], [1]], pattern)
with pytest.raises(EinopsError):
unpack_and_pack(x, [[4], [1], [1]], pattern)
# all correct, with -1
unpack_and_pack(x, [[2], [1], [-1]], pattern)
unpack_and_pack(x, [[2], [-1], [2]], pattern)
unpack_and_pack(x, [[-1], [1], [2]], pattern)
_, _, last = unpack_and_pack(x, [[2], [3], [-1]], pattern)
assert last.shape[case.dim()] == 0
# asking for more elements than available
with pytest.raises(EinopsError):
unpack(x, [[2], [4], [-1]], pattern)
# this one does not raise, because indexing x[2:1] just returns zero elements
# with pytest.raises(EinopsError):
# unpack(x, [[2], [-1], [4]], pattern)
with pytest.raises(EinopsError):
unpack(x, [[-1], [1], [5]], pattern)
# all correct, -1 nested
rs = unpack_and_pack(x, [[1, 2], [1, 1], [-1, 1]], pattern)
assert all(len(r.shape) == len(x.shape) + 1 for r in rs)
rs = unpack_and_pack(x, [[1, 2], [1, -1], [1, 1]], pattern)
assert all(len(r.shape) == len(x.shape) + 1 for r in rs)
rs = unpack_and_pack(x, [[2, -1], [1, 2], [1, 1]], pattern)
assert all(len(r.shape) == len(x.shape) + 1 for r in rs)
# asking for more elements, -1 nested
with pytest.raises(EinopsError):
unpack(x, [[-1, 2], [1], [5]], pattern)
with pytest.raises(EinopsError):
unpack(x, [[2, 2], [2], [5, -1]], pattern)
# asking for non-divisible number of elements
with pytest.raises(EinopsError):
unpack(x, [[2, 1], [1], [3, -1]], pattern)
with pytest.raises(EinopsError):
unpack(x, [[2, 1], [3, -1], [1]], pattern)
with pytest.raises(EinopsError):
unpack(x, [[3, -1], [2, 1], [1]], pattern)
# -1 takes zero
unpack_and_pack(x, [[0], [5], [-1]], pattern)
unpack_and_pack(x, [[0], [-1], [5]], pattern)
unpack_and_pack(x, [[-1], [5], [0]], pattern)
# -1 takes zero, -1
unpack_and_pack(x, [[2, -1], [1, 5]], pattern)
def test_pack_unpack_against_numpy():
for backend in collect_test_backends(symbolic=False, layers=False):
print(f"test packing against numpy for {backend.framework_name}")
check_zero_len = True
for case in cases:
unpack_and_pack = unpack_and_pack_against_numpy
shape = case.shape
pattern = case.pattern
x = rng.random(shape)
x = backend.from_numpy(x)
# all correct, no minus 1
unpack_and_pack(x, [[2], [1], [2]], pattern)
# no -1, asking for wrong shapes
with pytest.raises(EinopsError):
unpack(x, [[2], [1], [1]], pattern)
with pytest.raises(EinopsError):
unpack(x, [[4], [1], [1]], pattern)
# all correct, with -1
unpack_and_pack(x, [[2], [1], [-1]], pattern)
unpack_and_pack(x, [[2], [-1], [2]], pattern)
unpack_and_pack(x, [[-1], [1], [2]], pattern)
# asking for more elements than available
with pytest.raises(EinopsError):
unpack(x, [[2], [4], [-1]], pattern)
# this one does not raise, because indexing x[2:1] just returns zero elements
# with pytest.raises(EinopsError):
# unpack(x, [[2], [-1], [4]], pattern)
with pytest.raises(EinopsError):
unpack(x, [[-1], [1], [5]], pattern)
# all correct, -1 nested
unpack_and_pack(x, [[1, 2], [1, 1], [-1, 1]], pattern)
unpack_and_pack(x, [[1, 2], [1, -1], [1, 1]], pattern)
unpack_and_pack(x, [[2, -1], [1, 2], [1, 1]], pattern)
# asking for more elements, -1 nested
with pytest.raises(EinopsError):
unpack(x, [[-1, 2], [1], [5]], pattern)
with pytest.raises(EinopsError):
unpack(x, [[2, 2], [2], [5, -1]], pattern)
# asking for non-divisible number of elements
with pytest.raises(EinopsError):
unpack(x, [[2, 1], [1], [3, -1]], pattern)
with pytest.raises(EinopsError):
unpack(x, [[2, 1], [3, -1], [1]], pattern)
with pytest.raises(EinopsError):
unpack(x, [[3, -1], [2, 1], [1]], pattern)
if check_zero_len:
# -1 takes zero
unpack_and_pack(x, [[2], [3], [-1]], pattern)
unpack_and_pack(x, [[0], [5], [-1]], pattern)
unpack_and_pack(x, [[0], [-1], [5]], pattern)
unpack_and_pack(x, [[-1], [5], [0]], pattern)
# -1 takes zero, -1
unpack_and_pack(x, [[2, -1], [1, 5]], pattern)
def test_pack_unpack_array_api():
import numpy as xp
from einops import array_api as AA
if xp.__version__ < "2.0.0":
pytest.skip()
for case in cases:
shape = case.shape
pattern = case.pattern
x_np = rng.random(shape)
x_xp = xp.from_dlpack(x_np)
for ps in [
[[2], [1], [2]],
[[1], [1], [-1]],
[[1], [1], [-1, 3]],
[[2, 1], [1, 1, 1], [-1]],
]:
x_np_split = unpack(x_np, ps, pattern)
x_xp_split = AA.unpack(x_xp, ps, pattern)
for a, b in zip(x_np_split, x_xp_split):
assert np.allclose(a, AA.asnumpy(b + 0))
x_agg_np, ps1 = pack(x_np_split, pattern)
x_agg_xp, ps2 = AA.pack(x_xp_split, pattern)
assert ps1 == ps2
assert np.allclose(x_agg_np, AA.asnumpy(x_agg_xp))
for ps in [
[[2, 3]],
[[1], [5]],
[[1], [5], [-1]],
[[1], [2, 3]],
[[1], [5], [-1, 2]],
]:
with pytest.raises(EinopsError):
unpack(x_np, ps, pattern)
@@ -0,0 +1,126 @@
import pytest
from einops import EinopsError
from einops.parsing import AnonymousAxis, ParsedExpression, _ellipsis
__author__ = "Alex Rogozhnikov"
class AnonymousAxisPlaceholder:
def __init__(self, value: int):
self.value = value
assert isinstance(self.value, int)
def __eq__(self, other):
return isinstance(other, AnonymousAxis) and self.value == other.value
def test_anonymous_axes():
a, b = AnonymousAxis("2"), AnonymousAxis("2")
assert a != b
c, d = AnonymousAxisPlaceholder(2), AnonymousAxisPlaceholder(3)
assert a == c and b == c
assert a != d and b != d
assert [a, 2, b] == [c, 2, c]
def test_elementary_axis_name():
for name in [
"a",
"b",
"h",
"dx",
"h1",
"zz",
"i9123",
"somelongname",
"Alex",
"camelCase",
"u_n_d_e_r_score",
"unreasonablyLongAxisName",
]:
assert ParsedExpression.check_axis_name(name)
for name in ["", "2b", "12", "_startWithUnderscore", "endWithUnderscore_", "_", "...", _ellipsis]:
assert not ParsedExpression.check_axis_name(name)
def test_invalid_expressions():
# double ellipsis should raise an error
ParsedExpression("... a b c d")
with pytest.raises(EinopsError):
ParsedExpression("... a b c d ...")
with pytest.raises(EinopsError):
ParsedExpression("... a b c (d ...)")
with pytest.raises(EinopsError):
ParsedExpression("(... a) b c (d ...)")
# double/missing/enclosed parenthesis
ParsedExpression("(a) b c (d ...)")
with pytest.raises(EinopsError):
ParsedExpression("(a)) b c (d ...)")
with pytest.raises(EinopsError):
ParsedExpression("(a b c (d ...)")
with pytest.raises(EinopsError):
ParsedExpression("(a) (()) b c (d ...)")
with pytest.raises(EinopsError):
ParsedExpression("(a) ((b c) (d ...))")
# invalid identifiers
ParsedExpression("camelCase under_scored cApiTaLs ß ...")
with pytest.raises(EinopsError):
ParsedExpression("1a")
with pytest.raises(EinopsError):
ParsedExpression("_pre")
with pytest.raises(EinopsError):
ParsedExpression("...pre")
with pytest.raises(EinopsError):
ParsedExpression("pre...")
def test_parse_expression():
parsed = ParsedExpression("a1 b1 c1 d1")
assert parsed.identifiers == {"a1", "b1", "c1", "d1"}
assert parsed.composition == [["a1"], ["b1"], ["c1"], ["d1"]]
assert not parsed.has_non_unitary_anonymous_axes
assert not parsed.has_ellipsis
parsed = ParsedExpression("() () () ()")
assert parsed.identifiers == set()
assert parsed.composition == [[], [], [], []]
assert not parsed.has_non_unitary_anonymous_axes
assert not parsed.has_ellipsis
parsed = ParsedExpression("1 1 1 ()")
assert parsed.identifiers == set()
assert parsed.composition == [[], [], [], []]
assert not parsed.has_non_unitary_anonymous_axes
assert not parsed.has_ellipsis
aap = AnonymousAxisPlaceholder
parsed = ParsedExpression("5 (3 4)")
assert len(parsed.identifiers) == 3 and {i.value for i in parsed.identifiers} == {3, 4, 5}
assert parsed.composition == [[aap(5)], [aap(3), aap(4)]]
assert parsed.has_non_unitary_anonymous_axes
assert not parsed.has_ellipsis
parsed = ParsedExpression("5 1 (1 4) 1")
assert len(parsed.identifiers) == 2 and {i.value for i in parsed.identifiers} == {4, 5}
assert parsed.composition == [[aap(5)], [], [aap(4)], []]
parsed = ParsedExpression("name1 ... a1 12 (name2 14)")
assert len(parsed.identifiers) == 6
assert parsed.identifiers.difference({"name1", _ellipsis, "a1", "name2"}).__len__() == 2
assert parsed.composition == [["name1"], _ellipsis, ["a1"], [aap(12)], ["name2", aap(14)]]
assert parsed.has_non_unitary_anonymous_axes
assert parsed.has_ellipsis
assert not parsed.has_ellipsis_parenthesized
parsed = ParsedExpression("(name1 ... a1 12) name2 14")
assert len(parsed.identifiers) == 6
assert parsed.identifiers.difference({"name1", _ellipsis, "a1", "name2"}).__len__() == 2
assert parsed.composition == [["name1", _ellipsis, "a1", aap(12)], ["name2"], [aap(14)]]
assert parsed.has_non_unitary_anonymous_axes
assert parsed.has_ellipsis
assert parsed.has_ellipsis_parenthesized