einx: Universal Notation for Tensor Operations in Python

repository·master·Indexed 19 days ago

https://github.com/fferflo/einx

A Python library providing a universal string-based notation for formulating tensor operations across multiple frameworks, including Numpy, PyTorch, Jax, Tensorflow, MLX, and Tinygrad. It compiles expressive notation into efficient framework-specific code for operations such as reductions, permutations, splitting, and matrix multiplication. The library includes adapters to wrap custom Python functions and NumPy-like elementwise or reduction operations to be compatible with einx notation.

Tokens
37.1K
Snippets
133
Records
179
Agent score
66%

What's inside einx

  1. How einx notation works

    master

    einx provides a universal interface for tensor operations across frameworks like Numpy, PyTorch, Jax, and Tensorflow using a specific string notation. Every operation follows this pattern:

    outputs... = einx.{elementary_operation}("{vectorization}", inputs...)

    The vectorization string serves three purposes:

    1. Full Operation Signature: Defines the relationship between all input and output dimensions. Inputs and outputs are separated by ->, and tensors are separated by ,.

      • Example: "[c d] a, b -> a [e] b" accepts shapes (c, d, a) and (b), returning (a, e, b).
    2. Elementary Operation Signature: Sub-expressions in brackets [] define the dimensions that the core operation (e.g., sum, dot, add) actually operates on. Axes outside brackets are treated as vectorized (loop) axes.

      • Example: In "[c d] a, b -> a [e] b", the elementary operation acts on shapes (c, d) and () and returns (e).
    3. Vectorization: Axes not in brackets are handled via vectorization (analogous to nested loops).

      • Example: "[c d] a, b -> a [e] b" is equivalent to:
        for a in range(...):
            for b in range(...):
                z[a, :, b] = elementary_operation(x[:, :, a], y[b])

    Note: einx uses backend-optimized functions rather than literal Python loops.

    outputs... = einx.{elementary_operation}("{vectorization}", inputs...)
  2. Perform concatenation and splitting using einx.id

    master

    While einops.pack and einops.unpack use a special * notation for concatenation, einx handles these operations using its standard notation via einx.id. This allows for more expressive operations where input and output shapes do not align, and provides full documentation of all axes in the expression.

    Common concatenation patterns in einx:

    • Concatenate along a dimension: einx.id("a b c1, a b c2 -> a b (c1 + c2)", x, y)
    • Append a constant to a dimension: einx.id("b h w c1, -> b h w (c1 + 1)", img, 42.0)
    • Create mesh-grids: einx.id("x, y -> x y (1 + 1)", x, y)
    # Concatenation of tensors along the third dimension
    z = einx.id("a b c1, a b c2 -> a b (c1 + c2)", x, y)
    
    # Appending a number to the channel dimension of an image
    img_out = einx.id("b h w c1, -> b h w (c1 + 1)", img, 42.0)
    
    # Creating mesh-grids (similar to np.meshgrid)
    x = np.arange(64)
    y = np.arange(48)
    grid = einx.id("x, y -> x y (1 + 1)", x, y)
  3. Use numerical axes for inline length specification

    master

    For convenience, you can specify an axis length directly in the expression string by using a number instead of a name. A numerical axis is equivalent to introducing a new, unique axis name with that specific length constraint.

    Important: If you use the same number multiple times, einx treats them as different axes. For example, 3 3 in an expression refers to two distinct axes, both of length 3.

    # Using a numerical axis '3' to specify length inline
    y = einx.id("a b -> a b 3", x)
    
    # Equivalent to naming the axis and providing a constraint
    y = einx.id("a b -> a b c", x, c=3)
    
    # Multiple identical numbers refer to DIFFERENT axes
    y = einx.id("a b -> a b 3 3", x)
    # Equivalent to:
    # y = einx.id("a b -> a b c d", x, c=3, d=3)
  4. Using consistent vectorization patterns across different operations

    master

    In many libraries, specific vectorization patterns (like the Kronecker product) are only available for specific operations (like multiplication). einx allows you to apply the same vectorization pattern (the notation string) to any elementary operation. This means you can perform 'kron-like' operations for addition, comparison, or stacking just by changing the function name.

    # All these use the same vectorization pattern "a..., b... -> (a b)..."
    # but apply it to different elementary operations
    
    einx.multiply("a..., b... -> (a b)...", x, y)  # Similar to np.kron
    einx.add("a..., b... -> (a b)...", x, y)       # kron-like addition
    einx.less("a..., b... -> (a b)...", x, y)      # kron-like comparison
    einx.id("a..., b... -> (a b)... (1 + 1)", x, y) # kron-like stacking
  5. How einx handles repetition compared to einops

    master

    In einops, repetition is a special rule handled by a specific entry-point (einops.repeat) where values are repeated along any output axis that appears in the output expression but not in the input.

    In einx, repetition is treated as a general type of vectorization (an identity map) that can be applied to any operation. You do not need a separate function for repetition; instead, you use einx.id or include the new axis in the notation of other operations like einx.sum.

    # einops repetition
    z = einops.repeat(x, "a b -> a b c", c=3)
    
    # einx repetition (using identity map)
    z = einx.id("a b -> a b c", x, c=3)
    
    # einx repetition (within a reduction)
    z = einx.sum("a [b] -> a c", x, c=3)
  6. Use ellipses (...) for multi-dimensional axes

    master

    An ellipsis (...) in an einx expression represents multiple axes jointly. It is placed immediately after a sub-expression to indicate that the sub-expression is repeated zero or more times. The number of repetitions is automatically inferred from the input tensor shapes and any provided constraints.

    Key Behaviors:

    • Inference: The number of axes covered by the ellipsis is determined by the input dimensionality.
    • Consistency: If the same axis name is used with an ellipsis multiple times (e.g., s...), the number of repetitions must match across all occurrences. A mismatch raises an exception.
    • Composition: Ellipses can be applied to complex sub-expressions, including those with brackets [] or axis compositions.
    • Anonymous Ellipses: You can use ... without a preceding expression (e.g., ..., ... -> ...). In this case, einx generates a new, unique axis name for all occurrences.
    • Constraints: You can provide additional constraints for axes expanded by ellipses as either a tuple of integers (matching the repetition count) or a single integer that applies to all repetitions.
    import numpy as np
    import einx
    
    # 1. Basic ellipsis expansion
    x = np.random.randn(10, 20, 30, 40)
    y = einx.sum("s... [c] -> s...", x)
    # expands to: einx.sum("s1 s2 s3 [c] -> s1 s2 s3", x)
    
    # 2. Multiple ellipses with different sub-expressions
    x = np.random.randn(10, 20)
    y = np.random.randn(30, 40)
    z = einx.add("a..., b... -> a... b...", x, y)
    # expands to: einx.add("a1 a2, b1 b2 -> a1 a2 b1 b2", x, y)
    
    # 3. Ellipses with constraints
    # Using a tuple for specific sizes per repetition
    y = einx.id("a -> a b...", x, b=(5, 6))
    # expands to: einx.id("a -> a b1 b2", x, b1=5, b2=6)
    
    # Using a single integer for all repetitions
    y = einx.id("(a b)... -> a... b...", x, b=2)
    # expands to: einx.id("(a1 b1) (a2 b2) -> a1 a2 b1 b2", x, b1=2, b2=2)
    
    # 4. Anonymous ellipses
    z = einx.add("..., ... -> ...", x, y)
  7. Compare einx notation vs Numpy-like imperative operations

    master

    Einx is declarative: you declare what the input and output shapes look like, and the system handles the how. Numpy-like operations are imperative: you must explicitly call transpose, reshape, or newaxis to achieve the result.

    Taskeinx (Declarative)Numpy-like (Imperative)
    Transposeeinx.id("a b c -> b a c", x)np.transpose(x, (1, 0, 2))
    Add/Broadcasteinx.add("a d e, c b e -> a b c d e", x, y)x[:, np.newaxis, np.newaxis] + np.transpose(y, (1, 0, 2))[np.newaxis, :, :, np.newaxis]
    Stackingeinx.id("..., ... -> (1 + 1) ...", x, y)np.stack([x, y], axis=0)
    Concatenatingeinx.id("a ... , b ... -> (a + b) ...", x, y)np.concatenate([x, y], axis=0)
  8. Decoupling shape and operation in einx

    master

    A core advantage of einx is that it decouples the representation of an elementary operation from its vectorization (the shape of the tensors). This allows you to change the input/output shapes by simply updating the notation string, whereas traditional libraries (like NumPy or PyTorch) often require switching to different functions or adding manual rearrange steps when shapes change.

    Changing the Shape

    In einx, you can adapt to different indexing or broadcasting requirements by modifying the notation string while keeping the same entry-point function. For example, when moving from 1D indexing to more complex multidimensional indexing, einx handles the necessary rearrangements internally.

    # 1D indexing
    einx.get_at("[x] a, b -> b a", x, y)
    
    # 2D indexing (einx handles the complexity that would require manual rearrange in PyTorch)
    einx.get_at("[x y] b, c b a [2] -> c b a", x, y)
  9. Understand the difference between Classical and Named tensor notation

    master

    Tensor notation can be categorized into two main styles:

    1. Classical (Positional) Notation: Dimensions are identified by their position in the shape (e.g., the 1st, 2nd, or 3rd dimension). This often requires comments or strict conventions (like "channels-last") to prevent errors.
    2. Named Tensor Notation: Dimensions are annotated with symbolic names (e.g., batch, feature, time). This makes code self-documenting and allows operations to target specific axes by name rather than index.

    einx is compatible with both. An einx operation can match its string expression against either the positional shape of a classical tensor or the symbolic axis names of a named tensor.

    # Classical, positional-style tensor
    x = create_tensor((32, 128, 128))
    y = sum(x, axis=2)
    
    # Named tensor
    x = create_tensor({"batch": 32, "feature": 128, "time": 128})
    y = sum(x, axis="time")
    
    # einx works with both by matching names or positions
    y = einx.sum("batch feature [time]", x)
  10. Nest operators (-> and ,) within expressions

    master

    To write complex operations more concisely, you can nest the -> (output separator) or , (input separator) operators inside brackets []. When an operator is nested, einx expands the expression by moving these operators to the top level.

    Examples of nesting:

    • einx.{...}("a [b -> c]", x) expands to einx.{...}("a [b] -> a [c]", x)
    • einx.{...}("b p [i,->]", x, y) expands to einx.{...}("b p [i], b p -> b p", x, y)
    # Example of nesting -> inside brackets
    einx.id("a [b -> c]", x)
    # expands to: einx.id("a [b] -> a [c]", x)
  11. Squeeze axes with length 1

    master

    Any axis in an expression that has a length of 1 can be removed (squeezed). This can be done by explicitly specifying 1 in the expression or by using an axis name that matches a dimension of length 1 in the input tensor.

    x = np.random.randn(10, 1, 20)
    
    # Works: axis is explicitly specified with length 1
    y = einx.id("a 1 c -> a c", x)
    
    # Works: axis 'b' matches the input dimension of length 1
    y = einx.id("a b c -> a c", x)
    
    # Fails: axis 'a' has length 10, so it cannot be squeezed
    y = einx.id("a b c -> b c", x)
  12. Use composable ellipses in einx

    master

    In einx, ellipses (...) are composable. An ellipsis in an expression is defined to repeat the preceding expression. This allows for much more concise notation for n-dimensional operations compared to einops, where ellipses refer to a fixed set of primitive axes and cannot be composed with other axis compositions or ellipses.

    For example, a spatial mean-pooling operation that works for any number of dimensions can be written concisely in einx using a combination of axis composition and ellipses.

    # n-dimensional spatial mean-pooling in einx
    y = einx.mean("(s [ds])...", x, ds=4)
    
    # For a 2D input, this expands to: 
    # einx.mean("(s1 [ds1]) (s2 [ds2])...", x, ds1=4, ds2=4)
    
    # Depth-to-space operation using ellipses
    y = einx.id("b s... (c ds...) -> b (s ds)... c", ds=4)