You could use numpy broadcasting to get this result:
i = np.arange(3)[:, np.newaxis] # (3, 1)
# array([0, 1, 2])
j = np.arange(4)[np.newaxis, :] # (1, 4)
# array([0, 1, 2, 3])
arr = i * j # (3 ,4)
# array([[0, 0, 0, 0],
# [0, 1, 2, 3],
# [0, 2, 4, 6]])
You can perform most calculations you could want with the index arrays i [n x 1] and j [1 x m] and the result will be always be [n x m]
arr = np.sin(i)**2 + np.cos(i)**2 + (j-i)
# array([[ 1., 2., 3., 4.],
# [ 0., 1., 2., 3.],
# [-1., 0., 1., 2.]])
You could also use np.meshgrid() to explicitly repeat the indices along the other dimension to get full 2d arrays for i and j:
i, j = np.meshgrid(np.arange(3), np.arange(4), indexing='ij')
# i
# array([[0, 1, 2, 3],
# [0, 1, 2, 3],
# [0, 1, 2, 3]])
# j
# array([[0, 0, 0, 0],
# [1, 1, 1, 1],
# [2, 2, 2, 2]])
arr = i * j
# array([[0, 0, 0, 0],
# [0, 1, 2, 3],
# [0, 2, 4, 6]])
This is a good visualisation of what happens during broadcasting automatically.
Note the indexing argument ij of np.meshgrid() for matrix indexing; from the docs:
Giving the string ‘ij’ returns a meshgrid with matrix indexing, while ‘xy’ returns a meshgrid with Cartesian indexing. In the 2-D case with inputs of length M and N, the outputs are of shape (N, M) for ‘xy’ indexing and (M, N) for ‘ij’ indexing.