Multik 0.3.0 Help

Quickstart

The Basics

In Multik, as in many similar libraries, the NDArray object is of great importance. It represents homogeneous and single-typed numeric data with a multidimensional abstraction. For multidimensionality, the following concepts are employed:

  • Dimension — This mathematical concept refers to the number of coordinates required to determine a point in space. In our case, it refers to the necessary number of indexes for a data item. It is also often referred to as an axis.

  • Shape — This is a set of the actual sizes for each axis.

  • Strides — This is a set of the necessary number of steps to move to the next dimension in a given array. Steps are defined as indices; knowing what type of data the array stores, we can easily determine how many bytes are needed to move to the next axis.

These concepts help us operate on a simple array as if it were multidimensional. Let's take a closer look at the ndarray itself and the properties that describe this array.

Take a simple matrix, which is actually a two-dimensional array:

[[1.5, 2.1, 3.0], [4.0, 5.0, 6.0]]

Let's assign this matrix to a variable arr. Then the following properties are available:

  • arr.dim — Returns a dimension object, which characterizes the number of axes in the array. In this array, it will be 2D. There are several such objects: 1D, 2D, 3D, 4D, and ND. Therefore, if we are working with arrays up to the fifth dimension, we can easily check the legitimacy of such operations at compile time. For larger dimensions, we don't have this capability.

    • arr.dim.d — Returns the number of axes in the array.

  • arr.shape — This is an array containing the sizes of the array for each axis. In our array, it will return (2, 3), where 2 is the number of rows, and 3 is the number of columns in our matrix. The length of the shape is dim.d.

  • arr.size — The number of elements in the array. You can obtain it by multiplying each number in shape. Specifically, arr.size will return 6, and if you multiply each number in arr.shape, you will get the same result (2 * 3).

  • arr.dtype — The type of elements in the array. The supported types are: Byte, Short, Int, Long, Float, Double, ComplexFloat, and ComplexDouble. Because of the specific way arrays are stored, we can only operate with primitive types.

Array Creation

There are numerous ways to create an array. A straightforward way to do so is by using the mk[] structure, which should be passed to the mk.ndarray method. In this case, the array type will be inferred from the passed elements, and the dimension will be determined based on the nesting of these data. For instance:

val a = mk.ndarray(mk[1, 2, 3]) println(a.dtype) // DataType(nativeCode=3, itemSize=4, class=class kotlin.Int) println(a.dim) // dimension: 1 println(a) // [1, 2, 3] val b = mk.ndarray(mk[mk[1.5, 5.8], mk[9.1, 7.3]]) println(b.dtype) // DataType(nativeCode=6, itemSize=8, class=class kotlin.Double) println(b.dim) // dimension: 2 println(b) /* [[1.5, 5.8], [9.1, 7.3]] */

You can create an array from Kotlin collections and standard arrays.

mk.ndarray(setOf(1, 2, 3)) // [1, 2, 3] listOf(8.4, 5.2, 9.3, 11.5).toNDArray() // [8.4, 5.2, 9.3, 11.5]

Moreover, you can manually specify the size of each dimension. For example, you can create a three-dimensional array from a regular array in this way.

mk.ndarray(floatArrayOf(34.2f, 13.4f, 4.8f, 8.8f, 3.3f, 7.1f), 2, 1, 3) /* [[[34.2, 13.4, 4.8]], [[8.8, 3.3, 7.1]]] */

There are also standard functions that return an array filled with either zeros or ones, namely the zeros and ones functions. For these functions, you need to specify the element type.

mk.zeros<Int>(7) // [0, 0, 0, 0, 0, 0, 0] mk.ones<Float>(3, 2) /* [[1.0, 1.0], [1.0, 1.0], [1.0, 1.0]] */

In line with the Kotlin standard library, there are functions with a lambda.

mk.d3array(2, 2, 3) { it * it } // create an array of dimension 3 /* [[[0, 1, 4], [9, 16, 25]], [[36, 49, 64], [81, 100, 121]]] */ mk.d2arrayIndices(3, 3) { i, j -> ComplexFloat(i, j) } /* [[0.0+(0.0)i, 0.0+(1.0)i, 0.0+(2.0)i], [1.0+(0.0)i, 1.0+(1.0)i, 1.0+(2.0)i], [2.0+(0.0)i, 2.0+(1.0)i, 2.0+(2.0)i]] */

For numerical sequences, Multik provides two methods. arange returns an array within a given range, while linspace allows you to better control the number of numbers within a specified range.

mk.arange<Int>(3, 10, 2) // [3, 5, 7, 9] mk.linspace<Double>(0.0, 10.0, 8) // [0.0, 1.4285714285714286, 2.857142857142857, 4.285714285714286, 5.714285714285714, 7.142857142857143, 8.571428571428571, 10.0]

Arithmetic Operations

Arithmetic operations are performed element-wise on the array, resulting in a new array filled with the outcome of the operation.

When operating on a scalar and an array, only the type must match; the shape of the array is retained.

val a = mk.ndarray(mk[mk[1.5, 2.1, 3.0], mk[4.0, 5.0, 6.0]]) println(3.3 + a) /* [[4.8, 5.4, 6.3], [7.3, 8.3, 9.3]] */ println(a * 2.0) /* [[3.0, 4.2, 6.0], [8.0, 10.0, 12.0]] */

When conducting operations between two arrays, it's necessary that both the type, dimensionality, and shape of the arrays match. Dimensionality is checked at compile-time. However, shape conformity can only be verified at runtime. The operation remains element-wise, maintaining the original array shape.

val a = mk.ndarray(mk[mk[1.5, 2.1, 3.0], mk[4.0, 5.0, 6.0]]) val b = mk.ndarray(mk[mk[1.0, 1.3, 3.0], mk[4.0, 9.5, 5.0]]) a / b // division /* [[1.5, 1.6153846153846154, 1.0], [1.0, 0.5263157894736842, 1.2]] */

Please note that the multiplication operator * performs element-wise operations. For matrix product, use the dot method.

val a = mk.ndarray(mk[mk[0.5, 0.8, 0.0], mk[0.0, -4.5, 1.0]]) val b = mk.ndarray(mk[mk[1.0, 1.3, 3.0], mk[4.0, 9.5, 5.0]]) a * b // multiplication /* [[0.5, 1.04, 0.0], [0.0, -42.75, 5.0]] */

Operations such as +=, -=, /= and *= are designed to modify the current array directly, without creating a new one, i.e., in-place.

val a = mk.ndarray(mk[mk[1, 2], mk[3, 4]]) val b = mk.ndarray(mk[mk[4, 0], mk[7, 5]]) a += b println(a) /* [[5, 2], [10, 9]] */ a *= 3 println(a) /* [[15, 6], [30, 27]] */

Basic Operations

Although Multik's NDArray does not implement the Collection or Iterable interfaces, it does offer a subset of these methods for array operations. Functions such as filter, map, reduce, among others, are available for use with NDArray objects.

val a = mk.ndarray(mk[1, 2, 3, 4, 5]) val b = a.filter { it > 2 } println(b) // [3, 4, 5] val c = a.map { it * 2 } println(c) // [2, 4, 6, 8, 10] val d = a.reduce { acc, value -> acc + value } println(d) // 15

Indexing, Slicing and Iterating

Multik provides intuitive ways to index, slice, and iterate over NDArrays, similar to traditional collections with additional features for multidimensional arrays.

Indexing

In Multik, each index corresponds to a specific dimension (axis) of the array. Here's how you can access elements in an NDArray:

val a = mk.ndarray(mk[1, 2, 3]) a[2] // select the element at index 2 val b = mk.ndarray(mk[mk[1.5, 2.1, 3.0], mk[4.0, 5.0, 6.0]]) b[1, 2] // select the element at row 1 column 2

Slicing

Multik introduces slicing, a feature that allows for creating sequences from arrays. Slices are created by specifying the start, end, and step values within the array.

val b = mk.ndarray(mk[mk[1.5, 2.1, 3.0], mk[4.0, 5.0, 6.0]]) // select elements at rows 0 and 1 in column 1 b[0..<2, 1] // [2.1, 5.0]

If all indices are not provided, the unmentioned ones are considered to be full slices — i.e., slices from start to end with step 1 — retrieving all elements along the corresponding axis.

val b = mk.ndarray(mk[mk[1.5, 2.1, 3.0], mk[4.0, 5.0, 6.0]]) // select row 1 b[1] // [4.0, 5.0, 6.0] b[1, 0..2..1] // [4.0, 5.0, 6.0]

Iterating

Iterating over an NDArray in Multik is conducted element-wise, irrespective of the array's dimension:

val b = mk.ndarray(mk[mk[1.5, 2.1, 3.0], mk[4.0, 5.0, 6.0]]) for (el in b) { print("$el, ") // 1.5, 2.1, 3.0, 4.0, 5.0, 6.0, }

To facilitate easy navigation through multidimensional arrays, Multik provides multidimensional indices:

val b = mk.ndarray(mk[mk[1.5, 2.1, 3.0], mk[4.0, 5.0, 6.0]]) for (index in b.multiIndices) { print("${b[index]}, ") // 1.5, 2.1, 3.0, 4.0, 5.0, 6.0, }

Shape Manipulation

Copies and Views

18 July 2023