Basics

Install and set up:

(v1.1) pkg> add TensorCast
julia> using TensorCast

julia> V = [10,20,30];

julia> M = collect(reshape(1:12, 3,4))
3×4 Array{Int64,2}:
 1  4  7  10
 2  5  8  11
 3  6  9  12

Broadcasting

Here's a simple use of @cast, broadcasting addition over a matrix, a vector, and a scalar. Using := makes a new array, after which the left and right will be equal for all possible values of the indices:

julia> @cast A[j,i] := M[i,j] + 10 * V[i]
4×3 Array{Int64,2}:
 101  202  303
 104  205  306
 107  208  309
 110  211  312

julia> all(A[j,i] == M[i,j] + 10 * V[i] for i=1:3, j=1:4)
true

To make this happen, first M is transposed, and then the axis of V is re-oriented to lie in the second dimension. The macro @pretty prints out what @cast produces:

julia> @pretty @cast A[j,i] := M[i,j] + 10 * V[i]
begin
    local pelican = PermuteDims(M)
    local termite = orient(V, (*, :))
    A = @__dot__(pelican + 10termite)
end

Functions like orient take a code in which : represents an index which varies, while * is fixed. (Or fixed on a given slice, for sliceview below.) The reason to do this is that typeof(:) != typeof(*), and so this code is visible to the compiler, a trick I borrowed from JuliennedArrays.jl.

Of course @__dot__ is the full name of the broadcasting macro @., which simply produces A = pelican .+ 10 .* termite. And the animal names (in place of generated symbols) are from the indispensible MacroTools.jl.

Slicing

Here are two ways to slice M into its columns, both of these produce S = sliceview(M, (:, *)) which is simply collect(eachcol(M)):

julia> @cast S[j][i] := M[i,j]
4-element Array{SubArray{Int64,1,Array{Int64,2},Tuple{Base.Slice{Base.OneTo{Int64}},Int64},true},1}:
 [1, 2, 3]
 [4, 5, 6]   
 [7, 8, 9]   
 [10, 11, 12]

julia> all(S[j][i] == M[i,j] for i=1:3, j=1:4)
true

julia> @cast S2[j] := M[:,j]; 

julia> S == S2
true

We can glue slices back together with the same notation, M2[i,j] := S[j][i]. Combining this with slicing from M[:,j] is a convenient way to perform mapslices operations:

julia> @cast C[i,j] := cumsum(M[:,j])[i]
3×4 Array{Int64,2}:
 1   4   7  10
 3   9  15  21
 6  15  24  33

julia> C == mapslices(cumsum, M, dims=1)
true

This is both faster and more general than mapslices, as it does not have to infer what shape the function produces:

julia> using BenchmarkTools

julia> f(M) = @cast [i,j] := cumsum(M[:,j])[i];

julia> M10 = rand(10,1000);

julia> @btime mapslices(cumsum, $M10, dims=1);
  630.725 μs (7508 allocations: 400.28 KiB)

julia> @btime f($M10);
  64.056 μs (2006 allocations: 297.25 KiB)

It's also more readable, in less trivial examples:

julia> using LinearAlgebra, Random; Random.seed!(42);

julia> T = rand(3,3,5);

julia> @cast E[i,n] := eigen(T[:,:,n]).values[i];

julia> E == dropdims(mapslices(x -> eigen(x).values, T, dims=(1,2)), dims=2)
true

Fixed indices

Sometimes it's useful to insert a trivial index into the output – for instance to match the output of mapslices(x -> eigen(... just above:

julia> @cast E3[i,_,n] := eigen(T[:,:,n]).values[i];

julia> size(E3)
(3, 1, 5)

Using _ on the right will demand that size(E3,2) == 1; but you can also fix indices to other values. If these are variables not integers, they must be interpolated M[$row,j] to distinguish them from index names:

julia> col = 3;

julia> @cast O[i,j] := (M[i,1], M[j,$col])
3×3 Array{Tuple{Int64,Int64},2}:
 (1, 7)  (1, 8)  (1, 9)
 (2, 7)  (2, 8)  (2, 9)
 (3, 7)  (3, 8)  (3, 9)

Reshaping

Sometimes it's useful to combine two (or more) indices into one, which may be written either i⊗j or i\j or (i,j). The simplest version of this is precisely what the built-in function kron does:

julia> W = 1 ./ [10,20,30,40];

julia> @cast K[_, i⊗j] := V[i] * W[j]
1×12 Array{Float64,2}:
 1.0  2.0  3.0  0.5  1.0  1.5  0.333333  0.666667  1.0  0.25  0.5  0.75

julia> K == kron(W, V)'
true

julia> all(K[i + 3(j-1)] == V[i] * W[j] for i=1:3, j=1:4)
true

julia> Base.kron(A::Array{T,3}, X::Array{T′,3}) where {T,T′} =    # extend kron to 3-tensors
           @cast [x⊗a, y⊗b, z⊗c] := A[a,b,c] * X[x,y,z]

If an array on the right has a combined index, then it may be ambiguous how to divide up its range. You can resolve this by providing explicit ranges, after the main expression:

julia> @cast A[i,j] := collect(1:12)[i⊗j]  i:2
2×6 Array{Int64,2}:
 1  3  5  7   9  11
 2  4  6  8  10  12

julia> @cast A[i,j] := collect(1:12)[i⊗j]  i:4, j:3
4×3 Array{Int64,2}:
 1  5   9
 2  6  10
 3  7  11
 4  8  12

Writing into a given array with = instead of := will also remove ambiguities, as size(A) is known:

julia> @cast A[i,j] = 10 * collect(1:12)[i⊗j];

Aside, note that providing explicit ranges will also turn on checks of the input, for example:

julia> @pretty @cast W[i] := V[i]^2  i:3 
begin
    @assert_ 3 == size(V, 1) "range of index i must agree"
    @assert_ ndims(V) == 1 "expected a 1-tensor V[i]"
    W = @__dot__(V ^ 2)
end

Glue & reshape

As a less trivial example of these combined indices, here is one way to combine a list of matrices into one large grid. Notice that x varies faster than i, and so on – the linear order of index x⊗i agrees with that of two indices x,i.

julia> list = [ i .* ones(2,2) for i=1:8 ];

julia> @cast mat[x\i, y\j] := Int(list[i\j][x,y])  i:2
4×8 Array{Int64,2}:
 1  1  3  3  5  5  7  7
 1  1  3  3  5  5  7  7
 2  2  4  4  6  6  8  8
 2  2  4  4  6  6  8  8

julia> vec(mat) == @cast [xi\yj] := mat[xi, yj]
true

julia> mat == Int.(hvcat((4,4), transpose(reshape(list,2,4))...))
true

Alternatively, this reshapes each matrix to a vector, and makes them columns of the output:

julia> @cast colwise[x\y,i] := Int(list[i][x,y])
4×8 Array{Int64,2}:
 1  2  3  4  5  6  7  8
 1  2  3  4  5  6  7  8
 1  2  3  4  5  6  7  8
 1  2  3  4  5  6  7  8

julia> colwise == Int.(reduce(hcat, vec.(list)))
true

Reverse & shuffle

A minus in front of an index will reverse that direction, and a tilde will shuffle it. Both create views, which you may explicitly collect using |=:

julia> @cast M2[i,j] := M[i,-j]
3×4 view(::Array{Int64,2}, :, 4:-1:1) with eltype Int64:
 10  7  4  1
 11  8  5  2
 12  9  6  3

julia> using Random; Random.seed!(42); 

julia> @cast M3[i,j] |= M[i,~j]
3×4 Array{Int64,2}:
 7  4  1  10
 8  5  2  11
 9  6  3  12

Primes '

Acting on indices, A[i'] is normalised to A[i′] unicode \prime (which looks identical in some fonts). Acting on elements, C[i,j]' means adjoint.(C), elementwise complex conjugate, equivalent to (C')[j,i]. If the elements are matrices, as in C[:,:,k]', then adjoint is conjugate transpose.

julia> @cast C[i,i'] := (1:4)[i⊗i′] + im  (i:2, i′:2)
2×2 Array{Complex{Int64},2}:
 1+1im  3+1im
 2+1im  4+1im

julia> @cast [i,j] := C[i,j]'
2×2 Array{Complex{Int64},2}:
 1-1im  3-1im
 2-1im  4-1im

julia> C' == @cast [j,i] := C[i,j]'
true

Arrays of indices or functions

Besides arrays of numbers (and arrays of arrays) you can also broadcast an array of functions, which is done by calling apply(f,xs...) = f(xs...):

julia> funs = [identity, sqrt];

julia> @cast applied[i,j] := funs[i](V[j])
2×3 Array{Real,2}:
 10        20        30      
  3.16228   4.47214   5.47723

julia> @pretty @cast applied[i,j] := funs[i](V[j])
begin
    local pelican = orient(V, (*, :))
    applied = @__dot__(apply(funs, pelican))
end

You can also index one array using another, this example is just view(M, :, ind):

julia> ind = [1,1,2,2,4];

julia> @cast [i,j] := M[i,ind[j]]
3×5 view(::Array{Int64,2}, :, [1, 1, 2, 2, 4]) with eltype Int64:
 1  1  4  4  10
 2  2  5  5  11
 3  3  6  6  12

Repeated indices

The only situation in which repeated indices are allowed is when they either extract the diagonal of a matrix, or create a diagonal matrix:

julia> @cast D[i] |= C[i,i]
2-element Array{Complex{Int64},1}:
 1 + 1im
 4 + 1im

julia> D2 = @cast [i,i] := V[i]
3×3 Diagonal{Int64,Array{Int64,1}}:
 10   ⋅   ⋅
  ⋅  20   ⋅
  ⋅   ⋅  30

All indices appearing on the left must also appear on the right. There is no implicit sum over repeated indices on different tensors. To sum over things, you need @reduce or @matmul.