How to restructure arrays with zip/unzip and project#

Hide code cell content
%config InteractiveShell.ast_node_interactivity = "last_expr_or_assign"

Unzipping an array of records#

As discussed in How to create arrays of records, in addition to primitive types like numpy.float64 and numpy.datetime64, Awkward Arrays can also contain records. These records are formed from a fixed number of optionally named fields.

import awkward as ak
import numpy as np

records = ak.Array(
    [
        {"x": 1, "y": 1.1, "z": "one"},
        {"x": 2, "y": 2.2, "z": "two"},
        {"x": 3, "y": 3.3, "z": "three"},
        {"x": 4, "y": 4.4, "z": "four"},
        {"x": 5, "y": 5.5, "z": "five"},
    ]
)
[{x: 1, y: 1.1, z: 'one'},
 {x: 2, y: 2.2, z: 'two'},
 {x: 3, y: 3.3, z: 'three'},
 {x: 4, y: 4.4, z: 'four'},
 {x: 5, y: 5.5, z: 'five'}]
----------------------------
type: 5 * {
    x: int64,
    y: float64,
    z: string
}

Although it is useful to be able to create arrays from a sequence of records (as arrays of structures), Awkward Array implements arrays as structures of arrays. It is therefore more natural to think about arrays in terms of their fields. In the above example, we have created an array of records from a list of dictionaries. We can see that the x field of records contains five numpy.int64 values:

records.x
[1,
 2,
 3,
 4,
 5]
---------------
type: 5 * int64

If we wanted to look at each of the fields of records, we could pull them out individually from the array:

records.y
[1.1,
 2.2,
 3.3,
 4.4,
 5.5]
-----------------
type: 5 * float64
records.z
['one',
 'two',
 'three',
 'four',
 'five']
----------------
type: 5 * string

Clearly, for arrays with a large number of fields, retrieving each field in this manner would become tedious rather quickly. ak.unzip() can be used to directly build a tuple of the field arrays:

ak.unzip(records)
(<Array [1, 2, 3, 4, 5] type='5 * int64'>,
 <Array [1.1, 2.2, 3.3, 4.4, 5.5] type='5 * float64'>,
 <Array ['one', 'two', 'three', 'four', 'five'] type='5 * string'>)

Records are not required to have field names. A record without field names is known as a “tuple”, e.g.

tuples = ak.Array(
    [
        (1, 1.1, "one"),
        (2, 2.2, "two"),
        (3, 3.3, "three"),
        (4, 4.4, "four"),
        (5, 5.5, "five"),
    ]
)
[(1, 1.1, 'one'),
 (2, 2.2, 'two'),
 (3, 3.3, 'three'),
 (4, 4.4, 'four'),
 (5, 5.5, 'five')]
-------------------
type: 5 * (
    int64,
    float64,
    string
)

If we unzip an array of tuples, we obtain the same result as for records:

ak.unzip(tuples)
(<Array [1, 2, 3, 4, 5] type='5 * int64'>,
 <Array [1.1, 2.2, 3.3, 4.4, 5.5] type='5 * float64'>,
 <Array ['one', 'two', 'three', 'four', 'five'] type='5 * string'>)

ak.unzip() can be combined with ak.fields() to build a mapping from field name to field array:

dict(zip(ak.fields(records), ak.unzip(records)))
{'x': <Array [1, 2, 3, 4, 5] type='5 * int64'>,
 'y': <Array [1.1, 2.2, 3.3, 4.4, 5.5] type='5 * float64'>,
 'z': <Array ['one', 'two', 'three', 'four', 'five'] type='5 * string'>}

For tuples, the field names will be strings corresponding to the field index:

dict(zip(ak.fields(tuples), ak.unzip(tuples)))
{'0': <Array [1, 2, 3, 4, 5] type='5 * int64'>,
 '1': <Array [1.1, 2.2, 3.3, 4.4, 5.5] type='5 * float64'>,
 '2': <Array ['one', 'two', 'three', 'four', 'five'] type='5 * string'>}

Zipping together arrays#

Because Awkward Arrays unzip into distinct arrays, it is reasonable to ask whether the reverse is possible, i.e. given the following arrays

age = ak.Array([18, 32, 87, 55])
name = ak.Array(["Dorit", "Caitlin", "Theodor", "Albano"]);

can we form an array of records? The ak.zip() function provides a way to join compatible arrays into a single array of records:

people = ak.zip({"age": age, "name": name})
[{age: 18, name: 'Dorit'},
 {age: 32, name: 'Caitlin'},
 {age: 87, name: 'Theodor'},
 {age: 55, name: 'Albano'}]
----------------------------
type: 4 * {
    age: int64,
    name: string
}

Similarly, we could also build an array of tuples by passing a sequence of arrays:

ak.zip([age, name])
[(18, 'Dorit'),
 (32, 'Caitlin'),
 (87, 'Theodor'),
 (55, 'Albano')]
-----------------
type: 4 * (
    int64,
    string
)

Zipping and unzipping arrays is a lightweight operation, and so you should not hesitate to zip together arrays if it makes sense for the problem at hand. One of the benefits of combining arrays into an array of records is that slicing and masking operations are applied to all fields, e.g.

people[age > 35]
[{age: 87, name: 'Theodor'},
 {age: 55, name: 'Albano'}]
----------------------------
type: 2 * {
    age: int64,
    name: string
}

Arrays with different dimensions#

So far, we’ve looked at simple arrays with the same dimension in each field. It is actually possible to build arrays with fields of different dimensions, e.g.

x = ak.Array(
    [
        103,
        450,
        33,
        4,
    ]
)

digits_of_x = ak.Array(
    [
        [1, 0, 3],
        [4, 5, 0],
        [3, 3],
        [4],
    ]
)
x_and_digits = ak.zip({"x": x, "digits": digits_of_x})
[[{x: 103, digits: 1}, {x: 103, digits: 0}, {x: 103, digits: 3}],
 [{x: 450, digits: 4}, {x: 450, digits: 5}, {x: 450, digits: 0}],
 [{x: 33, digits: 3}, {x: 33, digits: 3}],
 [{x: 4, digits: 4}]]
-----------------------------------------------------------------
type: 4 * var * {
    x: int64,
    digits: int64
}

The type of this array is

x_and_digits.type
ArrayType(ListType(RecordType([NumpyType('int64'), NumpyType('int64')], ['x', 'digits'])), 4)

Note that the x field has changed type:

x.type
ArrayType(NumpyType('int64'), 4)
x_and_digits.x.type
ArrayType(ListType(NumpyType('int64')), 4)

In zipping the two arrays together, the x has been broadcast against digits_of_x. Sometimes you might want to limit the broadcasting to a particular depth (dimension). This can be done by passing the depth_limit parameter:

x_and_digits = ak.zip({"x": x, "digits": digits_of_x}, depth_limit=1)
[{x: 103, digits: [1, 0, 3]},
 {x: 450, digits: [4, 5, 0]},
 {x: 33, digits: [3, 3]},
 {x: 4, digits: [4]}]
-----------------------------
type: 4 * {
    x: int64,
    digits: var * int64
}

Now the x field has a single dimension

x_and_digits.x.type
ArrayType(NumpyType('int64'), 4)

Arrays with different dimension lengths#

What happens if we zip together arrays with the same dimensions, but different lengths in each dimensions?

x_and_y = ak.Array(
    [
        [103, 903],
        [450, 83],
        [33, 8],
        [4, 109],
    ]
)

digits_of_x_and_y = ak.Array(
    [
        [1, 0, 3, 9, 0, 3],
        [4, 5, 0, 8, 3],
        [3, 3, 8],
        [4, 1, 0, 9],
    ]
)

ak.zip({"x_and_y": x_and_y, "digits": digits_of_x_and_y})
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[21], line 19
      1 x_and_y = ak.Array(
      2     [
      3         [103, 903],
   (...)
      7     ]
      8 )
     10 digits_of_x_and_y = ak.Array(
     11     [
     12         [1, 0, 3, 9, 0, 3],
   (...)
     16     ]
     17 )
---> 19 ak.zip({"x_and_y": x_and_y, "digits": digits_of_x_and_y})

File ~/micromamba-root/envs/awkward-docs/lib/python3.10/site-packages/awkward/operations/ak_zip.py:145, in zip(arrays, depth_limit, parameters, with_name, right_broadcast, optiontype_outside_record, highlevel, behavior)
     20 """
     21 Args:
     22     arrays (dict or iterable of arrays): Each value in this dict or iterable
   (...)
    130     <Array [None, (2, 5), None] type='3 * ?(int64, int64)'>
    131 """
    132 with ak._errors.OperationErrorContext(
    133     "ak.zip",
    134     {
   (...)
    143     },
    144 ):
--> 145     return _impl(
    146         arrays,
    147         depth_limit,
    148         parameters,
    149         with_name,
    150         right_broadcast,
    151         optiontype_outside_record,
    152         highlevel,
    153         behavior,
    154     )

File ~/micromamba-root/envs/awkward-docs/lib/python3.10/site-packages/awkward/operations/ak_zip.py:239, in _impl(arrays, depth_limit, parameters, with_name, right_broadcast, optiontype_outside_record, highlevel, behavior)
    236     else:
    237         return None
--> 239 out = ak._broadcasting.broadcast_and_apply(
    240     layouts, action, behavior, right_broadcast=right_broadcast
    241 )
    242 assert isinstance(out, tuple) and len(out) == 1
    243 out = out[0]

File ~/micromamba-root/envs/awkward-docs/lib/python3.10/site-packages/awkward/_broadcasting.py:1060, in broadcast_and_apply(inputs, action, behavior, depth_context, lateral_context, allow_records, left_broadcast, right_broadcast, numpy_to_regular, regular_to_jagged, function_name, broadcast_parameters_rule)
   1058 backend = ak._backends.backend_of(*inputs)
   1059 isscalar = []
-> 1060 out = apply_step(
   1061     backend,
   1062     broadcast_pack(inputs, isscalar),
   1063     action,
   1064     0,
   1065     depth_context,
   1066     lateral_context,
   1067     behavior,
   1068     {
   1069         "allow_records": allow_records,
   1070         "left_broadcast": left_broadcast,
   1071         "right_broadcast": right_broadcast,
   1072         "numpy_to_regular": numpy_to_regular,
   1073         "regular_to_jagged": regular_to_jagged,
   1074         "function_name": function_name,
   1075         "broadcast_parameters_rule": broadcast_parameters_rule,
   1076     },
   1077 )
   1078 assert isinstance(out, tuple)
   1079 return tuple(broadcast_unpack(x, isscalar, backend) for x in out)

File ~/micromamba-root/envs/awkward-docs/lib/python3.10/site-packages/awkward/_broadcasting.py:1039, in apply_step(backend, inputs, action, depth, depth_context, lateral_context, behavior, options)
   1037     return result
   1038 elif result is None:
-> 1039     return continuation()
   1040 else:
   1041     raise ak._errors.wrap_error(AssertionError(result))

File ~/micromamba-root/envs/awkward-docs/lib/python3.10/site-packages/awkward/_broadcasting.py:756, in apply_step.<locals>.continuation()
    753         else:
    754             nextinputs.append(x)
--> 756 outcontent = apply_step(
    757     backend,
    758     nextinputs,
    759     action,
    760     depth + 1,
    761     copy.copy(depth_context),
    762     lateral_context,
    763     behavior,
    764     options,
    765 )
    766 assert isinstance(outcontent, tuple)
    767 parameters = parameters_factory(len(outcontent))

File ~/micromamba-root/envs/awkward-docs/lib/python3.10/site-packages/awkward/_broadcasting.py:1039, in apply_step(backend, inputs, action, depth, depth_context, lateral_context, behavior, options)
   1037     return result
   1038 elif result is None:
-> 1039     return continuation()
   1040 else:
   1041     raise ak._errors.wrap_error(AssertionError(result))

File ~/micromamba-root/envs/awkward-docs/lib/python3.10/site-packages/awkward/_broadcasting.py:919, in apply_step.<locals>.continuation()
    917     nextinputs.append(fcn(x, offsets))
    918 elif isinstance(x, listtypes):
--> 919     nextinputs.append(x._broadcast_tooffsets64(offsets).content)
    921 # Handle implicit left-broadcasting (non-NumPy-like broadcasting).
    922 elif options["left_broadcast"] and isinstance(x, Content):

File ~/micromamba-root/envs/awkward-docs/lib/python3.10/site-packages/awkward/contents/listoffsetarray.py:328, in ListOffsetArray._broadcast_tooffsets64(self, offsets)
    321 nextcarry = ak.index.Index64.empty(offsets[-1], self._backend.index_nplike)
    322 assert (
    323     nextcarry.nplike is self._backend.index_nplike
    324     and offsets.nplike is self._backend.index_nplike
    325     and starts.nplike is self._backend.index_nplike
    326     and stops.nplike is self._backend.index_nplike
    327 )
--> 328 self._handle_error(
    329     self._backend[
    330         "awkward_ListArray_broadcast_tooffsets",
    331         nextcarry.dtype.type,
    332         offsets.dtype.type,
    333         starts.dtype.type,
    334         stops.dtype.type,
    335     ](
    336         nextcarry.data,
    337         offsets.data,
    338         offsets.length,
    339         starts.data,
    340         stops.data,
    341         self._content.length,
    342     )
    343 )
    345 nextcontent = self._content._carry(nextcarry, True)
    347 return ListOffsetArray(offsets, nextcontent, parameters=self._parameters)

File ~/micromamba-root/envs/awkward-docs/lib/python3.10/site-packages/awkward/contents/content.py:247, in Content._handle_error(self, error, slicer)
    244 message += filename
    246 if slicer is None:
--> 247     raise ak._errors.wrap_error(ValueError(message))
    248 else:
    249     raise ak._errors.index_error(self, slicer, message)

ValueError: while calling

    ak.zip(
        arrays = {'x_and_y': <Array [[103, 903], [450, 83], [33, ...], [4, 10...
        depth_limit = None
        parameters = None
        with_name = None
        right_broadcast = False
        optiontype_outside_record = False
        highlevel = True
        behavior = None
    )

Error details: cannot broadcast nested list (in compiled code: https://github.com/scikit-hep/awkward/blob/awkward-cpp-8/awkward-cpp/src/cpu-kernels/awkward_ListArray_broadcast_tooffsets.cpp#L27)

Arrays which cannot be broadcast against each other will raise a ValueError. In this case, we want to stop broadcasting at the first dimension (depth_limit=1)

ak.zip({"x_and_y": x_and_y, "digits": digits_of_x_and_y}, depth_limit=1)
[{x_and_y: [103, 903], digits: [1, 0, 3, ..., 0, 3]},
 {x_and_y: [450, 83], digits: [4, 5, 0, 8, 3]},
 {x_and_y: [33, 8], digits: [3, 3, 8]},
 {x_and_y: [4, 109], digits: [4, 1, 0, 9]}]
-----------------------------------------------------
type: 4 * {
    x_and_y: var * int64,
    digits: var * int64
}

Projecting arrays#

Sometimes we are interested only in a subset of the fields of an array. For example, imagine that we have an array of coordinates on the \(\hat{x}\hat{y}\) plane:

triangle = ak.Array(
    [
        {"x": 1, "y": 6, "z": 0},
        {"x": 2, "y": 7, "z": 0},
        {"x": 3, "y": 8, "z": 0},
    ]
)
[{x: 1, y: 6, z: 0},
 {x: 2, y: 7, z: 0},
 {x: 3, y: 8, z: 0}]
--------------------
type: 3 * {
    x: int64,
    y: int64,
    z: int64
}

If we know that these points should lie on a plane, then we might wish to discard the \(\hat{z}\) coordinate. We can do this by slicing only the \(\hat{x}\) and \(\hat{y}\) fields:

triangle_2d = triangle[["x", "y"]]
[{x: 1, y: 6},
 {x: 2, y: 7},
 {x: 3, y: 8}]
--------------
type: 3 * {
    x: int64,
    y: int64
}

Note that the key passed to the subscript operator is a list ["x", "y"], not a tuple. Awkward Array recognises the list to mean “take both the "x" and "y" fields”.

Projections can be combined with array slicing and masking, e.g.

triangle_2d_first_2 = triangle[:2, ["x", "y"]]
[{x: 1, y: 6},
 {x: 2, y: 7}]
--------------
type: 2 * {
    x: int64,
    y: int64
}

Let’s now consider an array of triangles, i.e. a polygon:

triangles = ak.Array(
    [
        [
            {"x": 1, "y": 6, "z": 0},
            {"x": 2, "y": 7, "z": 0},
            {"x": 3, "y": 8, "z": 0},
        ],
        [
            {"x": 4, "y": 9, "z": 0},
            {"x": 5, "y": 10, "z": 0},
            {"x": 6, "y": 11, "z": 0},
        ],
    ]
)
[[{x: 1, y: 6, z: 0}, {x: 2, y: 7, z: 0}, {x: 3, y: 8, z: 0}],
 [{x: 4, y: 9, z: 0}, {x: 5, y: 10, z: 0}, {x: 6, y: 11, z: 0}]]
----------------------------------------------------------------
type: 2 * var * {
    x: int64,
    y: int64,
    z: int64
}

We can combine an int index 0 with a str projection to view the "x" coordinates of the first triangle vertices

triangles[0, "x"]
[1,
 2,
 3]
---------------
type: 3 * int64

We could even ignore the first vertex of each triangle

triangles[0, 1:, "x"]
[2,
 3]
---------------
type: 2 * int64

Projections commute (to the left) with other indices to produce the same result as their “natural” position. This means that the above projection could also be written as

triangles[0, "x", 1:]
[2,
 3]
---------------
type: 2 * int64

or even

triangles["x", 0, 1:]
[2,
 3]
---------------
type: 2 * int64

For columnar Awkward Arrays, there is no performance difference between any of these approaches; projecting the records of an array just changes its metadata, rather than invoking any loops over the data.

Projecting records-of-records#

The records of an array can themselves contain records

polygon = ak.Array(
    [
        {
            "vertex": [
                {"x": 1, "y": 6, "z": 0},
                {"x": 2, "y": 7, "z": 0},
                {"x": 3, "y": 8, "z": 0},
            ],
            "normal": [
                {"x": 0.164, "y": 0.986, "z": 0.0},
                {"x": 0.275, "y": 0.962, "z": 0.0},
                {"x": 0.351, "y": 0.936, "z": 0.0},
            ],
            "n_vertex": 3,
        },
        {
            "vertex": [
                {"x": 4, "y": 9, "z": 0},
                {"x": 5, "y": 10, "z": 0},
                {"x": 6, "y": 11, "z": 0},
                {"x": 7, "y": 12, "z": 0},
            ],
            "normal": [
                {"x": 0.406, "y": 0.914, "z": 0.0},
                {"x": 0.447, "y": 0.894, "z": 0.0},
                {"x": 0.470, "y": 0.878, "z": 0.0},
                {"x": 0.504, "y": 0.864, "z": 0.0},
            ],
            "n_vertex": 4,
        },
    ]
)
[{vertex: [{x: 1, y: 6, z: 0}, ..., {...}], normal: [...], n_vertex: 3},
 {vertex: [{x: 4, y: 9, z: 0}, ..., {...}], normal: [...], n_vertex: 4}]
------------------------------------------------------------------------
type: 2 * {
    vertex: var * {
        x: int64,
        y: int64,
        z: int64
    },
    normal: var * {
        x: float64,
        y: float64,
        z: float64
    },
    n_vertex: int64
}

Naturally we can access the "vertex" field with the . operator:

polygon.vertex
[[{x: 1, y: 6, z: 0}, {x: 2, y: 7, z: 0}, {x: 3, y: 8, z: 0}],
 [{x: 4, y: 9, z: 0}, {x: 5, y: 10, z: 0}, {...}, {x: 7, y: 12, z: 0}]]
-----------------------------------------------------------------------
type: 2 * var * {
    x: int64,
    y: int64,
    z: int64
}

We can view the "x" field of the vertex array with an additional lookup

polygon.vertex.x
[[1, 2, 3],
 [4, 5, 6, 7]]
---------------------
type: 2 * var * int64

The . operator represents the simplest slice of a single string, i.e.

polygon["vertex"]
[[{x: 1, y: 6, z: 0}, {x: 2, y: 7, z: 0}, {x: 3, y: 8, z: 0}],
 [{x: 4, y: 9, z: 0}, {x: 5, y: 10, z: 0}, {...}, {x: 7, y: 12, z: 0}]]
-----------------------------------------------------------------------
type: 2 * var * {
    x: int64,
    y: int64,
    z: int64
}

The slice corresponding to the nested lookup .vertex.x is given by a tuple of str:

polygon[("vertex", "x")]
[[1, 2, 3],
 [4, 5, 6, 7]]
---------------------
type: 2 * var * int64

It is even possible to combine multiple and single projections. Let’s project the "x" field of the "vertex" and "normal" fields:

polygon[["vertex", "normal"], "x"]
[{vertex: [1, 2, 3], normal: [0.164, ..., 0.351]},
 {vertex: [4, 5, 6, 7], normal: [0.406, ..., 0.504]}]
-----------------------------------------------------
type: 2 * {
    vertex: var * int64,
    normal: var * float64
}