The key to working with 'xyi,xyj->xyij' is that xy repeated in the output line.
Let us use a simpler array:
x = np.arange(6).reshape(3,2) np.einsum.einsum('ij->j',x) # array([6, 9]) # sums on the 1st dimension of 'x'
Now for the external product on this x :
In [20]: x[:,:,None]*x[:,None,:] # shape (3,2,2) Out[20]: array([[[ 0, 0], [ 0, 1]], [[ 4, 6], [ 6, 9]], [[16, 20], [20, 25]]])
This is an example of simple broadcasting (i.e. adding dimensions and expanding them)
In "...i,...j->...ij" , ... functions more as a placeholder for existing but anonymous dimensions.
Equivalent with einsum :
np.einsum('ij,ik->ijk',x,x)
(I really have to do a calculation that is not symmetrical in the last two dimensions).
I developed pure Python, similar to einsum . The focus is on parsing the argument string and how it creates input for the iter object. It is available on github: https://github.com/hpaulj/numpy-einsum/blob/master/einsum_py.py. You can experiment with it. It has a debug flag to show intermediate steps.
With my einsum with debug output:
In [23]: einsum_py.myeinsum('ij,ik->ijk',x,x,debug=True)
op_axes is the key argument that is used when creating iter , an object that iterates over the axes of the input and output arrays. Iterates over the 1st axis of all arrays. The 2nd axis is 1 for the 1st operation and exit, and newaxis (-1) for the 2nd operation.
With ellipsis :
In [24]: einsum_py.myeinsum('...j,...k->...jk',x,x,debug=True) ... iter labels: [0, 106, 107],'0jk' op_axes [[0, 1, -1], [0, -1, 1], [0, 1, 2]]
This generates the same op_axes and therefore the same calculations.