Suggestion for a bit of code concerning matrix structure to throw at readers during or before the "Python Basics with Numpy" assignment

So I was playing around with the numpy array data structure and it struck me that a summarizing piece of code, somewhere at the start of “Python Basics with Numpy”, might be of interest to the student who wants to be shown some multidimensional (really: multiaxial) array handling code. Maybe one could consider extending the exercise accordingly:

import numpy as np

def examine(m):
    if isinstance(m, (int, float, complex)):
        # NUMERIC, note that "float" covers the case "NaN"
        # (special Not-a-Number number) and "inf" (infinity)
        print(f"REJECTED: type is purely numeric: {type(m)}")
        print("---------------")
        return
    if type(m) == list or type(m) == tuple:
        # DUBIOUS, one could convert those to Numpy array though
        print(f"REJECTED: type is {type(m)} of length {len(m)}")
        print("---------------")
        return
    elif type(m) == np.ndarray:
        # GOOD: Numpy array
        print(f"The type of m is: {type(m)}")
        print(f"The shape of m is: {m.shape}")
        print(f"The type of elements held by m is: {m.dtype}")
    else:
        # BAD: Might be anything
        print(f"REJECTED: type is {type(m)}, very unexpected")
        print("---------------")
        return

    # Distinguish cases by the number of "axes" of the Numpy multidimensional
    # array. The number of axes is commonly called "number of dimensions",
    # which is incorrect if you think about it, as this is not a dimension
    # along which to measure some value but an axis along which to arrange
    # independent values. In fact, each cell in a matrix corresponds to
    # a dimension in and of itself. A 2x2 matrix properly has 4 dimensions!
    # But - as customarily, naming is messed up and firmly entrenched.

    if m.ndim == 1:
        print("SUSPECT: 1-dimensional array")
        print(f"The length along the unique axis is: {m.shape[0]}")
    elif m.ndim == 2 and m.shape[0] == 1 and m.shape[1] == 1:
        print("SPECIAL CASE: 1x1 matrix")
    elif m.ndim == 2 and m.shape[1] == 1 and m.shape[0] > 1:
        print(f"SPECIAL CASE: matrix shaped as single column of length {m.shape[0]}")
    elif m.ndim == 2 and m.shape[0] == 1 and m.shape[1] > 1:
        print(f"SPECIAL CASE: matrix shaped as single row of length {m.shape[1]}")
    elif m.ndim == 2 and m.shape[0] > 1 and m.shape[0] == m.shape[1]:
        print(f"SPECIAL CASE: square matrix of common length {m.shape[0]} along both axes")
    else:
        print(f"GENERAL CASE: matrix with {m.ndim} axes")
        for axis in range(0, m.ndim):
            if axis == m.ndim - 1:
                alias = " (axis of columns)"
            elif axis == m.ndim - 2:
                alias = " (axis of rows)"
            elif axis == m.ndim - 3:
                alias = " (axis of layers)" # is 'layers' a good name?
            else:
                alias = "" # we don't know what to call even larger groups, maybe 'frame'
            print(f"The length along axis {axis}{alias} is: {m.shape[axis]}")
    print(str(m))
    print("---------------")


# This will be rejected as a string.
examine("a")

# This will be rejected as a purely numeric 'int'.
examine(1)

# This will be rejected as a purely numeric 'complex'.
examine(1 + 2j)

# This will be rejected as a 'list' (of length 1)
examine([1])

# This will be rejected as a 'list' (of length 3)
examine([1, 2, 3])

# This will be rejected as a purely numeric int.
# This is *not* a 'tuple' of length 1, just an 'int' in parentheses.
examine((1))

# This will be rejected as a tuple (of length 1).
# Python distinguishes a "number in parentheses" from a "1-element tuple"
# by the - only apparently useless - trailing comma (a parsing ambiguity of
# the language that had to be solved somehow)
examine((1,))

# This will be rejected as a 'tuple' (of length 3)
examine((1, 2, 3))

# A 'numpy array' that just has 1 dimension (I prefer 'just has 1 axis')
examine(np.array([1]))

# Another 'numpy array' that just has 1 dimension (I prefer 'just has 1 axis')
examine(np.array([1, 2, 3]))

# A 'numpy array' that is a matrix of size 1x1 (2 dimensions, a 2D-matrix with 1 cell)
examine(np.array([[1]]))

# A 'numpy array' that is a 1x3 matrix
examine(np.array([[1, 2, 3]]))

# A 'numpy array' that is a 3x1 matrix
examine(np.array([[1], [2], [3]]))

# A 'numpy array' that is 2x2 square matrix
examine(np.array([[1, 2], [3, 4]]))

# A 'numpy array' that is a 3x5 matrix (written as 3 rows of 5 entries/columns)
examine(np.array([[1, 2, 3, 4, 5],
                  [6, 7, 8, 9, 10],
                  [11, 12, 13, 14, 15]]))

# A 'numpy array' that is a 3x5x2 matrix (3 'layers' of 5 rows of 2 entries/columns)
# 'layer' sounds like a good word for the largest element
examine(np.array(
    [[[1, 2],
      [3, 4],
      [5, 6],
      [7, 8],
      [9, 10]],
     [[11, 12],
      [13, 14],
      [15, 16],
      [17, 18],
      [19, 20]],
     [[21, 22],
      [23, 24],
      [25, 26],
      [27, 28],
      [29, 30]]]))

# A 'numpy array' that is a 2x3x4x5 matrix, which we build explicitly.
# We name the hierarchy: 2 tops -> 3 layers -> 4 rows -> 5 entries/columns
mx = np.zeros((2, 3, 4, 5), dtype=int)
for top_index in range(0, mx.shape[0]):
    for layer_index in range(0, mx.shape[1]):
        for row_index in range(0, mx.shape[2]):
            for col_index in range(0, mx.shape[3]):
                mx[top_index, layer_index, row_index, col_index] = (((top_index + 1) * 10 + (layer_index + 1)) * 10 + (row_index + 1)) * 10 + (col_index + 1)
examine(mx)

The above gives the following output:

REJECTED: type is <class 'str'>, very unexpected
---------------
REJECTED: type is purely numeric: <class 'int'>
---------------
REJECTED: type is purely numeric: <class 'complex'>
---------------
REJECTED: type is <class 'list'> of length 1
---------------
REJECTED: type is <class 'list'> of length 3
---------------
REJECTED: type is purely numeric: <class 'int'>
---------------
REJECTED: type is <class 'tuple'> of length 1
---------------
REJECTED: type is <class 'tuple'> of length 3
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (1,)
The type of elements held by m is: int64
SUSPECT: 1-dimensional array
The length along the unique axis is: 1
[1]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (3,)
The type of elements held by m is: int64
SUSPECT: 1-dimensional array
The length along the unique axis is: 3
[1 2 3]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (1, 1)
The type of elements held by m is: int64
SPECIAL CASE: 1x1 matrix
[[1]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (1, 3)
The type of elements held by m is: int64
SPECIAL CASE: matrix shaped as single row of length 3
[[1 2 3]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (3, 1)
The type of elements held by m is: int64
SPECIAL CASE: matrix shaped as single column of length 3
[[1]
 [2]
 [3]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (2, 2)
The type of elements held by m is: int64
SPECIAL CASE: square matrix of common length 2 along both axes
[[1 2]
 [3 4]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (3, 5)
The type of elements held by m is: int64
GENERAL CASE: matrix with 2 axes
The length along axis 0 (axis of rows) is: 3
The length along axis 1 (axis of columns) is: 5
[[ 1  2  3  4  5]
 [ 6  7  8  9 10]
 [11 12 13 14 15]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (3, 5, 2)
The type of elements held by m is: int64
GENERAL CASE: matrix with 3 axes
The length along axis 0 (axis of layers) is: 3
The length along axis 1 (axis of rows) is: 5
The length along axis 2 (axis of columns) is: 2
[[[ 1  2]
  [ 3  4]
  [ 5  6]
  [ 7  8]
  [ 9 10]]

 [[11 12]
  [13 14]
  [15 16]
  [17 18]
  [19 20]]

 [[21 22]
  [23 24]
  [25 26]
  [27 28]
  [29 30]]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (2, 3, 4, 5)
The type of elements held by m is: int64
GENERAL CASE: matrix with 4 axes
The length along axis 0 is: 2
The length along axis 1 (axis of layers) is: 3
The length along axis 2 (axis of rows) is: 4
The length along axis 3 (axis of columns) is: 5
[[[[1111 1112 1113 1114 1115]
   [1121 1122 1123 1124 1125]
   [1131 1132 1133 1134 1135]
   [1141 1142 1143 1144 1145]]

  [[1211 1212 1213 1214 1215]
   [1221 1222 1223 1224 1225]
   [1231 1232 1233 1234 1235]
   [1241 1242 1243 1244 1245]]

  [[1311 1312 1313 1314 1315]
   [1321 1322 1323 1324 1325]
   [1331 1332 1333 1334 1335]
   [1341 1342 1343 1344 1345]]]


 [[[2111 2112 2113 2114 2115]
   [2121 2122 2123 2124 2125]
   [2131 2132 2133 2134 2135]
   [2141 2142 2143 2144 2145]]

  [[2211 2212 2213 2214 2215]
   [2221 2222 2223 2224 2225]
   [2231 2232 2233 2234 2235]
   [2241 2242 2243 2244 2245]]

  [[2311 2312 2313 2314 2315]
   [2321 2322 2323 2324 2325]
   [2331 2332 2333 2334 2335]
   [2341 2342 2343 2344 2345]]]]
---------------

Hi, David.

That’s beautiful! Thanks for putting that together and sharing it. I had previously had that same idea of creating a “telltale” 4D array, but it was in the context of demonstrating how the “reshape” function works. You might find this thread worth a look.

I’m not sure we have a way to get them to incorporate something like that into that Intro assignment, but I will bookmark your post and try to bring more attention to it at the least.

I think the terminology that they use for dimensions is in sync with how we talk about that in analytic geometry. Consider what we mean by “3D space” in geometry: you have 3 spatial dimensions and you have three “axes”, x, y and z, corresponding to those dimensions, right? How is that different than what we have here with multidimensional arrays?

1 Like

Thank you Paulin.

It was just one of those things that always irked me. :grin: Please feel free to ignore.

Consider a 3d-space: an element in that space is a point, which can be named by 3 independent numbers, its 3d-coordinates.

However, a “3-dimensional matrix” is not at all like that. It is a “bag of numbers”, or cells, with the cell arranged in a discrete, numbered 3d space. Each cell can be named by 3 independent numbers, its coordinates. So the cell coordinate system is 3d. However, the matrix itself has much higher dimension: it contains as many independent numbers as cells, for example, for a 2x2x2 matrix, there are 8 independent numbers, i.e. 8 dimensions.

But everybody indeed calls a 2x2x2 matrix a “3-dimensional matrix”, so … can’t change that, can we.

Addendum

Found a few problems and unclarities in the code. Fixed now!

But the point is that each element in that “bag of numbers” is exactly a point in 3d space in the case of a 3d matrix, right? My interpretation of your words is that we agree on that. If I have such a “bag” with 100 elements in it or 1000, we still call it a “3d matrix” because its contents can be thought of as points in 3d space. How many elements the matrix contains is a separate issue.

But as you say, the usage as described is very widespread and we pretty much have to “go with the flow” in terms of the common terminology. But when you are the course designer or the author of the book, then you’re welcome to do it your way! :nerd_face:

1 Like

We are definitely not in disagreement :grin:

It’s just a matter of usage and taste. Phil Karton said “There are only two hard things in Computer Science: cache invalidation and naming things.” And this is true in math, too. And in the passage from math to computing.

But let’s get back to code. I have discovered a few more special cases (in particular, Numpy allows 0-dimensional matrices :ok_hand: and has its own numeric data types) and extended the original program. I cannot change the original post, so I will append it here.

import numpy as np

def examine(m):
    if isinstance(m, (int, float, complex, np.float64, np.float32, np.int32, np.int64)):
        # NUMERIC
        # 'float' covers the case "NaN" (special Not-a-Number number) and "inf" (infinity)
        # 'float' subsumes 'np.float64' but not 'np.float32'
        # 'int' is the Python 'arbitrary precision integer', so one wants Numpy's int32 or int64
        # 'int' does not subsume either of 'np.int32' or 'np.int64'
        print(f"REJECTED: type is purely numeric: {type(m)}")
        print("---------------")
        return
    if type(m) == list or type(m) == tuple:
        # DUBIOUS, one could convert those to Numpy array though
        print(f"REJECTED: type is {type(m)} of length {len(m)}")
        print("---------------")
        return
    elif type(m) == np.ndarray:
        # GOOD: Numpy array
        print(f"The type of m is: {type(m)}")
        print(f"The shape of m is: {m.shape}")
        print(f"The type of elements held by m is: {m.dtype}")
    else:
        # BAD: Might be anything
        print(f"REJECTED: type is {type(m)}, very unexpected")
        print("---------------")
        return

    # Distinguish cases by the number of "axes" of the Numpy multidimensional
    # array. The number of axes is commonly called "number of dimensions",
    # which is incorrect if you think about it, as this is not a dimension
    # along which to measure some value but an axis along which to arrange
    # independent values. In fact, each cell in a matrix corresponds to
    # a dimension in and of itself. A 2x2 matrix properly has 4 dimensions!
    # But - as customarily, naming is messed up and firmly entrenched.

    if m.ndim == 0:
        print("SUSPECT: 0-dimensional array")
        print(f"The wrapped scalar is: {m.item()}");
    elif m.ndim == 1:
        print("SUSPECT: 1-dimensional array (neither row nor column vector)")
        print(f"The length along the unique dimension/axis is: {m.shape[0]}")
    elif m.ndim == 2 and m.shape[0] == 1 and m.shape[1] == 1:
        print("SPECIAL CASE: 1x1 matrix")
        print(f"The wrapped scalar is: {m.item()} (via item) or {m.squeeze()} (via squeeze)");
    elif m.ndim == 2 and m.shape[1] == 1 and m.shape[0] > 1:
        print(f"SPECIAL CASE: matrix shaped as single column of length {m.shape[0]}")
    elif m.ndim == 2 and m.shape[0] == 1 and m.shape[1] > 1:
        print(f"SPECIAL CASE: matrix shaped as single row of length {m.shape[1]}")
    elif m.ndim == 2 and m.shape[0] > 1 and m.shape[0] == m.shape[1]:
        print(f"SPECIAL CASE: square matrix of common length {m.shape[0]} along both axes")
    else:
        print(f"GENERAL CASE: matrix with {m.ndim} axes")
        for axis in range(0, m.ndim):
            if axis == m.ndim - 1:
                alias = " (axis of columns)"
            elif axis == m.ndim - 2:
                alias = " (axis of rows)"
            elif axis == m.ndim - 3:
                alias = " (axis of layers)" # is 'layers' a good name?
            else:
                alias = "" # we don't know what to call even larger groups, maybe 'frame'
            print(f"The length along axis {axis}{alias} is: {m.shape[axis]}")
    print(str(m))
    print("---------------")


# This will be rejected as a string.
examine("a")

# This will be rejected as a purely numeric 'int'.
examine(1)

# This will be rejected as a purely numeric 'float' (64 bit float, built-in)
examine(1.0)

# This will be rejected as a purely numeric Numpy 32 bit float
examine(np.float32(3.14))

# This will be rejected as a purely numeric Numpy 64 bit float
examine(np.float64(6.28))

# This will be rejected as a purely numeric Numpy 32 bit int
examine(np.int32(771))

# This will be rejected as a purely numeric Numpy 64 bit int
examine(np.int64(772))

# This will be rejected as a purely numeric 'complex'.
examine(1 + 2j)

# This will be rejected as a 'list' (of length 1)
examine([1])

# This will be rejected as a 'list' (of length 3)
examine([1, 2, 3])

# This will be rejected as a purely numeric int.
# This is *not* a 'tuple' of length 1, just an 'int' in parentheses.
examine((1))

# This will be rejected as a tuple (of length 1).
# Python distinguishes a "number in parentheses" from a "1-element tuple"
# by the - only apparently useless - trailing comma (a parsing ambiguity of
# the language that had to be solved somehow)
examine((1,))

# This will be rejected as a 'tuple' (of length 3)
examine((1, 2, 3))

# A 'numpy array' that just has 0 dimensions (i.e. no axis at all)
examine(np.array(42))

# A 'numpy array' that has 1 dimension (1 a single axis along which cells are arranged)
examine(np.array([1]))

# Another 'numpy array' that has 1 dimension
examine(np.array([1, 2, 3]))

# A 'numpy array' that is a matrix of size 1x1 (2 dimensions/axes along
# which cells are arranged, a 2d-matrix with 1 cell only)
examine(np.array([[1]]))

# A 'numpy array' that is a 1x3 matrix (~ one row)
examine(np.array([[1, 2, 3]]))

# A 'numpy array' that is a 3x1 matrix (~ one column)
examine(np.array([[1], [2], [3]]))

# A 'numpy array' that is 2x2 square matrix
examine(np.array([[1, 2], [3, 4]]))

# A 'numpy array' that is 1x1x3 matrix (1 row in a 3d matrix)
examine(np.array([[[1, 2, 3]]]))

# A 'numpy array' that is 1x3x1 matrix (1 column in a 3d matrix)
examine(np.array([[[1],[2],[3]]]))

# A 'numpy array' that is 3x1x1 matrix (1 layer in a 3d matrix)
examine(np.array([[[1]],[[2]],[[3]]]))

# A 'numpy array' that is a 3x5 matrix (written as 3 rows of 5 elements)
examine(np.array([[1, 2, 3, 4, 5],
                  [6, 7, 8, 9, 10],
                  [11, 12, 13, 14, 15]]))

# A 'numpy array' that is a 3x5x2 matrix (3 'layers' of 5 rows of 2 elements)
# ('layer' sounds like a good word for the largest element)
examine(np.array(
    [[[1, 2],
      [3, 4],
      [5, 6],
      [7, 8],
      [9, 10]],
     [[11, 12],
      [13, 14],
      [15, 16],
      [17, 18],
      [19, 20]],
     [[21, 22],
      [23, 24],
      [25, 26],
      [27, 28],
      [29, 30]]]))

# A 'numpy array' that is a 2x3x4x5 matrix, which we build explicitly.
# We name the hierarchy: 2 tops -> 3 layers -> 4 rows -> 5 elements
mx = np.zeros((2, 3, 4, 5), dtype=int)
for top_index in range(0, mx.shape[0]):
    for layer_index in range(0, mx.shape[1]):
        for row_index in range(0, mx.shape[2]):
            for col_index in range(0, mx.shape[3]):
                mx[top_index, layer_index, row_index, col_index] = (((top_index + 1) * 10 + (layer_index + 1)) * 10 + (row_index + 1)) * 10 + (col_index + 1)
examine(mx)

And the ouput is:

REJECTED: type is <class 'str'>, very unexpected
---------------
REJECTED: type is purely numeric: <class 'int'>
---------------
REJECTED: type is purely numeric: <class 'float'>
---------------
REJECTED: type is purely numeric: <class 'numpy.float32'>
---------------
REJECTED: type is purely numeric: <class 'numpy.float64'>
---------------
REJECTED: type is purely numeric: <class 'numpy.int32'>
---------------
REJECTED: type is purely numeric: <class 'numpy.int64'>
---------------
REJECTED: type is purely numeric: <class 'complex'>
---------------
REJECTED: type is <class 'list'> of length 1
---------------
REJECTED: type is <class 'list'> of length 3
---------------
REJECTED: type is purely numeric: <class 'int'>
---------------
REJECTED: type is <class 'tuple'> of length 1
---------------
REJECTED: type is <class 'tuple'> of length 3
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: ()
The type of elements held by m is: int64
SUSPECT: 0-dimensional array
The wrapped scalar is: 42
42
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (1,)
The type of elements held by m is: int64
SUSPECT: 1-dimensional array (neither row nor column vector)
The length along the unique dimension/axis is: 1
[1]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (3,)
The type of elements held by m is: int64
SUSPECT: 1-dimensional array (neither row nor column vector)
The length along the unique dimension/axis is: 3
[1 2 3]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (1, 1)
The type of elements held by m is: int64
SPECIAL CASE: 1x1 matrix
The wrapped scalar is: 1 (via item) or 1 (via squeeze)
[[1]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (1, 3)
The type of elements held by m is: int64
SPECIAL CASE: matrix shaped as single row of length 3
[[1 2 3]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (3, 1)
The type of elements held by m is: int64
SPECIAL CASE: matrix shaped as single column of length 3
[[1]
 [2]
 [3]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (2, 2)
The type of elements held by m is: int64
SPECIAL CASE: square matrix of common length 2 along both axes
[[1 2]
 [3 4]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (1, 1, 3)
The type of elements held by m is: int64
GENERAL CASE: matrix with 3 axes
The length along axis 0 (axis of layers) is: 1
The length along axis 1 (axis of rows) is: 1
The length along axis 2 (axis of columns) is: 3
[[[1 2 3]]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (1, 3, 1)
The type of elements held by m is: int64
GENERAL CASE: matrix with 3 axes
The length along axis 0 (axis of layers) is: 1
The length along axis 1 (axis of rows) is: 3
The length along axis 2 (axis of columns) is: 1
[[[1]
  [2]
  [3]]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (3, 1, 1)
The type of elements held by m is: int64
GENERAL CASE: matrix with 3 axes
The length along axis 0 (axis of layers) is: 3
The length along axis 1 (axis of rows) is: 1
The length along axis 2 (axis of columns) is: 1
[[[1]]

 [[2]]

 [[3]]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (3, 5)
The type of elements held by m is: int64
GENERAL CASE: matrix with 2 axes
The length along axis 0 (axis of rows) is: 3
The length along axis 1 (axis of columns) is: 5
[[ 1  2  3  4  5]
 [ 6  7  8  9 10]
 [11 12 13 14 15]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (3, 5, 2)
The type of elements held by m is: int64
GENERAL CASE: matrix with 3 axes
The length along axis 0 (axis of layers) is: 3
The length along axis 1 (axis of rows) is: 5
The length along axis 2 (axis of columns) is: 2
[[[ 1  2]
  [ 3  4]
  [ 5  6]
  [ 7  8]
  [ 9 10]]

 [[11 12]
  [13 14]
  [15 16]
  [17 18]
  [19 20]]

 [[21 22]
  [23 24]
  [25 26]
  [27 28]
  [29 30]]]
---------------
The type of m is: <class 'numpy.ndarray'>
The shape of m is: (2, 3, 4, 5)
The type of elements held by m is: int64
GENERAL CASE: matrix with 4 axes
The length along axis 0 is: 2
The length along axis 1 (axis of layers) is: 3
The length along axis 2 (axis of rows) is: 4
The length along axis 3 (axis of columns) is: 5
[[[[1111 1112 1113 1114 1115]
   [1121 1122 1123 1124 1125]
   [1131 1132 1133 1134 1135]
   [1141 1142 1143 1144 1145]]

  [[1211 1212 1213 1214 1215]
   [1221 1222 1223 1224 1225]
   [1231 1232 1233 1234 1235]
   [1241 1242 1243 1244 1245]]

  [[1311 1312 1313 1314 1315]
   [1321 1322 1323 1324 1325]
   [1331 1332 1333 1334 1335]
   [1341 1342 1343 1344 1345]]]


 [[[2111 2112 2113 2114 2115]
   [2121 2122 2123 2124 2125]
   [2131 2132 2133 2134 2135]
   [2141 2142 2143 2144 2145]]

  [[2211 2212 2213 2214 2215]
   [2221 2222 2223 2224 2225]
   [2231 2232 2233 2234 2235]
   [2241 2242 2243 2244 2245]]

  [[2311 2312 2313 2314 2315]
   [2321 2322 2323 2324 2325]
   [2331 2332 2333 2334 2335]
   [2341 2342 2343 2344 2345]]]]
---------------