Reductions

julia> using TensorCast

julia> M = collect(reshape(1:12, 3,4));

This is the basic syntax to sum over one index:

julia> @reduce S[i] := sum(j) M[i,j] + 1000
3-element Vector{Int64}:
 4022
 4026
 4030

julia> @pretty @reduce S[i] := sum(j) M[i,j] + 1000
begin
    @boundscheck ndims(M) == 2 || throw(ArgumentError("expected a 2-tensor M[i, j]"))
    S = dropdims(sum(@__dot__(M + 1000), dims = 2), dims = 2)
end

Note that:

  • You must always specify the index to be summed over.
  • The sum applies to the whole right side (including the 1000 here).
  • And the summed dimensions are always dropped (unless you explicitly say S[i,_] := ...).

Not just sum

You may use any reduction funciton which understands keyword dims=..., like sum does. For example:

julia> using Statistics

julia> @reduce A[j] := mean(i) M[i,j]^2
4-element Vector{Float64}:
   4.666666666666667
  25.666666666666668
  64.66666666666667
 121.66666666666667

If writing into an existing array, then the function must work like sum! does:

julia> @pretty @reduce A[j] = mean(i) M[i,j]^2
begin
    local pelican = transmute(M, (2, 1))  # pelican = transpose(M)
    mean!(A, @__dot__(pelican ^ 2))
end

Otherwise all the same notation as @cast works. Here's a max-pooling trick with combined indices, in which we group the numbers in R into tiplets and keep the maximum of each set – equivalent to the maximum of each column below.

julia> R = collect(0:5:149);

julia> @reduce Rmax[_,c] := maximum(r) R[(r,c)]  r in 1:3
1×10 Matrix{Int64}:
 10  25  40  55  70  85  100  115  130  145

julia> reshape(R, 3,:)
3×10 Matrix{Int64}:
  0  15  30  45  60  75   90  105  120  135
  5  20  35  50  65  80   95  110  125  140
 10  25  40  55  70  85  100  115  130  145

Here r in 1:3 indicates the range of r, implying c ∈ 1:10. You may also write maximum(r:3) R[(r,c)].

In the same way, this down-samples an image by a factor of 2, taking the mean of each 2×2 block of pixels, in each colour c:

julia> @reduce smaller[I,J,c] := mean(i:2, j:2) orig[(i,I), (j,J), c]

Scalar output

Here are different ways to write complete reduction:

julia> @reduce Z := sum(i,j) M[i,j]      # Z = sum(M)
78

julia> Z = @reduce sum(i,j) M[i,j]
78

julia> @reduce Z0[] := sum(i,j) M[i,j]   # Z0 = dropdims(sum(M, dims=(1, 2)), dims=(1, 2))
0-dimensional Array{Int64, 0}:
78

julia> @reduce Z1[_] := sum(i,j) M[i,j]
1-element Vector{Int64}:
 78

Recursion

If you want to sum only part of an expression, or have one sum inside another, then you can place a complete @reduce expression inside @cast or @reduce. There is no need to name the intermediate array, here termite[x], but you must show its indices:

julia> @pretty @reduce sum(x,θ) L[x,θ] * p[θ] * log(L[x,θ] / @reduce _[x] := sum(θ′) L[x,θ′] * p[θ′])
begin
    ndims(L) == 2 || error()  # etc, some checks
    local goshawk = transmute(p, (nothing, 1))
    sandpiper = dropdims(sum(@__dot__(L * goshawk), dims = 2), dims = 2)  # inner sum
    bison = sum(@__dot__(L * goshawk * log(L / sandpiper)))
end

Notice that the inner sum here is a matrix-vector product, so it will be more efficient to write @matmul _[x] := sum(θ') L[x,θ′] * p[θ′] to call L * p instead of broadcasting.

Matrix multiplication

It's possible to multiply matrices using @reduce, but this is exceptionally inefficient, as it first broadcasts out a 3-tensor before summing over one index:

julia> @pretty @reduce R[i,k] := sum(j) M[i,j] * N[j,k]
begin
    size(M, 2) == size(N, 1) || error()  # etc, some checks
    local fish = transmute(N, (nothing, 1, 2))   # fish = reshape(N, 1, size(N)...)
    R = dropdims(sum(@__dot__(M * fish), dims = 2), dims = 2)
end

The macro @matmul has the same syntax, but instead calls A*B.

julia> A = rand(100,100); B = rand(100,100);

julia> red(A,B) = @reduce R[i,k] := sum(j) A[i,j] * B[j,k];

julia> mul(A,B) = @matmul P[i,k] := sum(j) A[i,j] * B[j,k];

julia> using BenchmarkTools

julia> @btime red($A, $B);
  1.471 ms (25 allocations: 7.71 MiB)

julia> @btime mul($A, $B);
  33.489 μs (2 allocations: 78.20 KiB)

Of course you may just write A * B yourself, but in general @matmul will handle the same reshaping, transposing, fixed indices, etc. steps as @reduce does. Once all of that is done, however, the result must be a product like A * B in which the indices being summed over appear on both tensors.

To use the more flexible @reduce in cases like this, when creating the large intermediate tensor will be expensive, the option @lazy which creates a LazyArrays.BroadcastArray instead can help:

julia> using LazyArrays

julia> lazyred(A,B) = @reduce @lazy R[i,k] := sum(j) A[i,j] * B[j,k];

julia> @btime lazyred($A, $B);
  223.172 μs (26 allocations: 78.95 KiB)  # when things worked well
  10.186 ms (23 allocations: 78.77 KiB)   # today

julia> red(A, B) ≈ mul(A, B) ≈ lazyred(A,B)
true

More details on the next page.

No reduction

The syntax of @reduce can also be used with @cast, to apply functions which take a dims keyword, but do not produce trivial dimensions.

julia> normalise(p; dims) = p ./ sum(p; dims=dims);

julia> @cast N[x,y] := normalise(x) M[x,y]
3×4 Matrix{Float64}:
 0.166667  0.266667  0.291667  0.30303
 0.333333  0.333333  0.333333  0.333333
 0.5       0.4       0.375     0.363636