Matrix Slicing

Affine matrix expressions form the core of PICOS’ modeling toolbox: All constant and variable expressions that you enter, including integral variables, and any linear combination of these objects, are stored as instances of the multidimensional ComplexAffineExpression class. This base class implements plenty of algebraic operations to combine and modify your initial expressions to yield the desired statements. One of these operations is slicing, denoted by A[·] for an affine expression A.

Preliminaries

Unlike in NumPy, all multidimensional expressions are strictly matrices. In particular, there are no flat arrays but only row and column vectors, and any scalar expression is also a 1 \times 1 matrix. PICOS does not support tensors or higher order expressions, but it does support the Kronecker product as well as partial trace and partial transposition operations to enable some of the optimization problems naturally defined on tensors. If you enter data in the form of a flat array (e.g. a Python list or a NumPy ndarray with one axis), it will be read as a column vector.

In PICOS, all indices start from zero.

Slicing modes

PICOS has two modes for slicing: Arbitrary Access and Proper Slicing.

Arbitrary Access lets you select individual elements from a vector or matrix expression and put them in a column vector in the desired order. Transposition, reshaping and broadcasting can then be used to put the selection into the desired shape. Arbitrary Access has the form A[·] where · stands for an integer, a Python slice, a flat collection of integers such as a list, or a dictionary storing sparse index pairs.

Proper Slicing refers to selecting certain rows and columns of a matrix, and forming a new matrix where all elements that are not selected are removed. It has the form A[·,·] where each · stands for an integer, a slice, or a flat collection of integers.

To demonstrate the different possibilities, we use a constant 5 \times 5 expression:

>>> from picos import Constant
>>> A = Constant("A", range(25), (5,5))
>>> A
<5×5 Real Constant: A>
>>> print(A)
[ 0.00e+00  5.00e+00  1.00e+01  1.50e+01  2.00e+01]
[ 1.00e+00  6.00e+00  1.10e+01  1.60e+01  2.10e+01]
[ 2.00e+00  7.00e+00  1.20e+01  1.70e+01  2.20e+01]
[ 3.00e+00  8.00e+00  1.30e+01  1.80e+01  2.30e+01]
[ 4.00e+00  9.00e+00  1.40e+01  1.90e+01  2.40e+01]

Arbitrary Access

By integer

If a single integer or a single flat collection of integers is given, then these indices refer to the column-major vectorization of the matrix, represented by the order of the numbers in the demonstration matrix A.

The most common case is selecting a single element via an integer index:

>>> A[0]  # Select the first element as a scalar expression.
<1×1 Real Constant: A[0]>
>>> print(A[0])  # Print its value.
0.0
>>> print(A[7])  # The value of the eighth element.
7.0
>>> # Negative indices are counted from the rear; -1 refers to the last element:
>>> print(A[-1])
24.0

By slice

Python slices allow you to compactly specify a structured sequence of elements to extract. A Python slice has the form a:b or a:b:s with a the inclusive start index, b the exclusive stop index and s a step size. Negative a and b, as in the integer index case, are counted from the rear, while a negative step size reverses the order. All of a, b and s may be omitted. Then, the defaults are

s &= 1, \\
a &= \begin{cases}
  0,~&\text{if}~s > 0, \\
  \text{len}(A) - 1,~&\text{if}~s < 0,
\end{cases} \\
b &= \begin{cases}
  \text{len}(A),~&\text{if}~s > 0, \\
  \textbf{None},~&\text{if}~s < 0.
\end{cases}

Note the None in the statement above: When going backwards, this special token is the only way to stop at the first element with index 0 as -1 refers to the last element. For example, the first two elements in reverse order are selected via the slice 1:None:-1 or just 1::-1.

>>> A[:2]  # The first two elements as a column vector.
<2×1 Real Constant: A[:2]>
>>> print(A[:2])
[ 0.00e+00]
[ 1.00e+00]
>>> print(A[1::-1])  # The first two elements reversed (indices 1 and 0).
[ 1.00e+00]
[ 0.00e+00]
>>> print(A[-2:])  # The last two elements.
[ 2.30e+01]
[ 2.40e+01]
>>> print(A[2:7].T)  # The third to seventh element (transposed).
[ 2.00e+00  3.00e+00  4.00e+00  5.00e+00  6.00e+00]
>>> print(A[2:7:2].T)  # As before, but with a step size of 2.
[ 2.00e+00  4.00e+00  6.00e+00]

You could use this to vectorize A in column-major order, but A.vec is both individually faster and has its result cached:

>>> A[:].equals(A.vec)
True
>>> A.vec is A.vec  # Cached.
True
>>> A[:] is A[:]  # Computed again as new expression.
False

By integer sequence

By providing a list or a similar vector of integers, you can select arbitrary elements in any order, including duplicates:

>>> print(A[[0,1,0,1,-1]])
[ 0.00e+00]
[ 1.00e+00]
[ 0.00e+00]
[ 1.00e+00]
[ 2.40e+01]

Note that you cannot provide a tuple instead of a list, as A[(·,·)] is understood by Python as A[·,·] (see Proper Slicing). Any other object that the function load_data with typecode="i" loads as an integer row or column vector works, including integral NumPy arrays.

By sparse index pair dictionary

If you provide a dictionary with exactly two keys that can be compared via < and whose values are integer sequences of same length (anything recognized by load_data as an integer vector), PICOS interprets the sequence corresponding to the smaller key as row indices and the sequence corresponding to the greater key as the corresponding column indices:

>>> print(A[{"x": range(3), "y": [1]*3}])  # Select (0,1), (1,1) and (2,1).
[ 5.00e+00]
[ 6.00e+00]
[ 7.00e+00]
>>> print(A[{"y": range(3), "x": [1]*3}])  # Transposed selection, as "x" < "y".
[ 1.00e+00]
[ 6.00e+00]
[ 1.10e+01]

You could use this to extract the main diagonal of A, but A.maindiag is both individually faster and has its result cached:

>>> indices = dict(enumerate([range(min(A.shape))]*2))
>>> indices
{0: range(0, 5), 1: range(0, 5)}
>>> A[indices].equals(A.maindiag)
True
>>> A.maindiag is A.maindiag  # Cached.
True
>>> A[indices] is A[indices]  # Computed again as new expression.
False

Proper Slicing

If you provide not one but two integers, slices, or integer sequences separated by a comma or given as a tuple, then they are understood as row and column indices, respectively. Unlike when providing a sparse index pair by dictionary, these indices select entire rows and columns and PICOS returns the matrix of all elements that are selected twice (both by row and by column):

>>> print(A[1,2])  # The single element at (1,2) (second row, third column).
11.0
>>> print(A[0,:])  # The first row of the matrix.
[ 0.00e+00  5.00e+00  1.00e+01  1.50e+01  2.00e+01]
>>> print(A[range(3),-1])  # The first three elements of the last column.
[ 2.00e+01]
[ 2.10e+01]
[ 2.20e+01]
>>> print(A[[0,1],[0,1]])  # The first second-order principal submatrix.
[ 0.00e+00  5.00e+00]
[ 1.00e+00  6.00e+00]
>>> print(A[1:-1,1:-1])  # Cut away the outermost pixels of an image.
[ 6.00e+00  1.10e+01  1.60e+01]
[ 7.00e+00  1.20e+01  1.70e+01]
[ 8.00e+00  1.30e+01  1.80e+01]
>>> print(A[::2,::2])  # Sample every second element.
[ 0.00e+00  1.00e+01  2.00e+01]
[ 2.00e+00  1.20e+01  2.20e+01]
[ 4.00e+00  1.40e+01  2.40e+01]

You can even select the entire matrix to effectively create a copy of it, though this is discouraged as expressions are supposed to be immutable so that reusing an expression in multiple places is always safe.

>>> A[:,:].equals(A)
True
>>> A[:,:] is A
False

We refer to this as proper slicing because you cut out the rows that you want, throwing away the rest, then cut the desired columns out from the remainder. It’s like cutting a square cake except that you can also duplicate the pieces!

Note

In NumPy, A[[0,1],[0,1]] would create a flat array with the elements A[0,0] and A[1,1] while PICOS creates a submatrix from the first two rows and columns as in the example above. If you want to mirror NumPy’s behavior in PICOS, see By sparse index pair dictionary.