Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Arrays irregulares, desiguales, Awkward Arrays

awkward

¿Qué es Awkward Array?

La lección anterior incluía una rebanada complicada:

corte = muones["nMuon"] == 2

pt0 = muones["Muon_pt", corte, 0]

Las tres partes de la rebanada muones["Muon_pt", corte, 0]

  1. seleccionan el campo "Muon_pt" de todos los registros del array,

  2. aplican corte, un array booleano, para seleccionar solo los eventos con dos muones,

  3. seleccionan el primer (0) muón de cada uno de esos pares. De manera similar para los segundos (1) muones.

NumPy no sería capaz de realizar una rebanada así, ni siquiera de representar un array de listas de longitud variable sin recurrir a arrays de objetos.

import numpy as np

# genera un ValueError
np.array([[0.0, 1.1, 2.2], [], [3.3, 4.4], [5.5], [6.6, 7.7, 8.8, 9.9]])
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[1], line 4
      1 import numpy as np
      2 
      3 # genera un ValueError
----> 4 np.array([[0.0, 1.1, 2.2], [], [3.3, 4.4], [5.5], [6.6, 7.7, 8.8, 9.9]])

ValueError: setting an array element with a sequence. The requested array has an inhomogeneous shape after 1 dimensions. The detected shape was (5,) + inhomogeneous part.

Awkward Array está diseñado para llenar este vacío:

import awkward as ak

ak.Array([[0.0, 1.1, 2.2], [], [3.3, 4.4], [5.5], [6.6, 7.7, 8.8, 9.9]])
Loading...

A los arrays como este se les llama a veces “arrays irregulares” o “desiguales” (en inglés, “jagged arrays” o “ragged arrays”).

Rebanadas en Awkward Array

Las rebanadas básicas son una generalización de las de NumPy: lo que NumPy haría si tuviera listas de longitud variable.

array = ak.Array([[0.0, 1.1, 2.2], [], [3.3, 4.4], [5.5], [6.6, 7.7, 8.8, 9.9]])
array.tolist()
[[0.0, 1.1, 2.2], [], [3.3, 4.4], [5.5], [6.6, 7.7, 8.8, 9.9]]
array[2]
Loading...
array[-1, 1]
np.float64(7.7)
array[2:, 0]
Loading...
array[2:, 1:]
Loading...
array[:, 0]
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[8], line 1
----> 1 array[:, 0]

File ~/miniconda3/envs/skhep-tutorial/lib/python3.12/site-packages/awkward/highlevel.py:1118, in Array.__getitem__(self, where)
    689 def __getitem__(self, where):
    690     """
    691     Args:
    692         where (many types supported; see below): Index of positions to
   (...)   1116     have the same dimension as the array being indexed.
   1117     """
-> 1118     with ak._errors.SlicingErrorContext(self, where):
   1119         # Handle named axis
   1120         (_, ndim) = self._layout.minmax_depth
   1121         named_axis = _get_named_axis(self)

File ~/miniconda3/envs/skhep-tutorial/lib/python3.12/site-packages/awkward/_errors.py:79, in ErrorContext.__exit__(self, exception_type, exception_value, traceback)
     77     self._slate.__dict__.clear()
     78     # Handle caught exception
---> 79     raise self.decorate_exception(exception_type, exception_value)
     80 else:
     81     # Step out of the way so that another ErrorContext can become primary.
     82     if self.primary() is self:

File ~/miniconda3/envs/skhep-tutorial/lib/python3.12/site-packages/awkward/highlevel.py:1126, in Array.__getitem__(self, where)
   1122 where = _normalize_named_slice(named_axis, where, ndim)
   1124 NamedAxis.mapping = named_axis
-> 1126 indexed_layout = prepare_layout(self._layout._getitem(where, NamedAxis))
   1128 if NamedAxis.mapping:
   1129     return ak.operations.ak_with_named_axis._impl(
   1130         indexed_layout,
   1131         named_axis=NamedAxis.mapping,
   (...)   1134         attrs=self._attrs,
   1135     )

File ~/miniconda3/envs/skhep-tutorial/lib/python3.12/site-packages/awkward/contents/content.py:651, in Content._getitem(self, where, named_axis)
    642 named_axis.mapping = _named_axis
    644 next = ak.contents.RegularArray(
    645     this,
    646     this.length,
    647     1,
    648     parameters=None,
    649 )
--> 651 out = next._getitem_next(nextwhere[0], nextwhere[1:], None)
    653 if out.length is not unknown_length and out.length == 0:
    654     return out._getitem_nothing()

File ~/miniconda3/envs/skhep-tutorial/lib/python3.12/site-packages/awkward/contents/regulararray.py:595, in RegularArray._getitem_next(self, head, tail, advanced)
    589 nextcontent = self._content._carry(nextcarry, True)
    591 if advanced is None or (
    592     advanced.length is not unknown_length and advanced.length == 0
    593 ):
    594     return RegularArray(
--> 595         nextcontent._getitem_next(nexthead, nexttail, advanced),
    596         nextsize,
    597         self.length,
    598         parameters=self._parameters,
    599     )
    600 else:
    601     nextadvanced = ak.index.Index64.empty(nextcarry.length, nplike)

File ~/miniconda3/envs/skhep-tutorial/lib/python3.12/site-packages/awkward/contents/listarray.py:764, in ListArray._getitem_next(self, head, tail, advanced)
    758 head = ak._slicing.normalize_integer_like(head)
    759 assert (
    760     nextcarry.nplike is self._backend.nplike
    761     and self._starts.nplike is self._backend.nplike
    762     and self._stops.nplike is self._backend.nplike
    763 )
--> 764 self._maybe_index_error(
    765     self._backend[
    766         "awkward_ListArray_getitem_next_at",
    767         nextcarry.dtype.type,
    768         self._starts.dtype.type,
    769         self._stops.dtype.type,
    770     ](
    771         nextcarry.data,
    772         self._starts.data,
    773         self._stops.data,
    774         lenstarts,
    775         head,
    776     ),
    777     slicer=head,
    778 )
    779 nextcontent = self._content._carry(nextcarry, True)
    780 return nextcontent._getitem_next(nexthead, nexttail, advanced)

File ~/miniconda3/envs/skhep-tutorial/lib/python3.12/site-packages/awkward/contents/content.py:297, in Content._maybe_index_error(self, error, slicer)
    295 else:
    296     message = self._backend.format_kernel_error(error)
--> 297     raise ak._errors.index_error(self, slicer, message)

IndexError: cannot slice ListArray (of length 5) with array(0): index out of range while attempting to get index 0 (in compiled code: https://github.com/scikit-hep/awkward/blob/awkward-cpp-56/awkward-cpp/src/cpu-kernels/awkward_ListArray_getitem_next_at.cpp#L21)

This error occurred while attempting to slice

    <Array [[0, 1.1, 2.2], ..., [6.6, 7.7, ..., 9.9]] type='5 * var * float64'>

with

    (:, 0)

Pregunta rápida: ¿por qué la última genera un error?

Las rebanadas con booleanos y con enteros también funcionan:

array[[True, False, True, False, True]]
Loading...
array[[2, 3, 3, 1]]
Loading...

Igual que en NumPy, se pueden calcular arrays booleanos para las rebanadas, y funciones como ak.num son útiles para eso.

ak.num(array)
Loading...
ak.num(array) > 0
Loading...
array[ak.num(array) > 0, 0]
Loading...
array[ak.num(array) > 1, 1]
Loading...

Ahora considera esto (similar a un ejemplo de la primera lección):

corte = array * 10 % 2 == 0

array[corte]
Loading...

Este array, corte, no es solo un array de booleanos. Es un array irregular de booleanos. Todas sus listas anidadas encajan en las listas anidadas de array, por lo que puede seleccionar números en profundidad, en lugar de seleccionar listas.

Aplicación: seleccionar partículas, en lugar de eventos

Volviendo al TTree grande de la lección anterior,

import uproot

url_archivo = "root://eospublic.cern.ch//eos/opendata/cms/derived-data/AOD2NanoAODOutreachTool/Run2012BC_DoubleMuParked_Muons.root"

# Si estás en Windows o no tienes XRootD instalado, puedes usar esta url en su lugar
# url_archivo = "https://root.cern/files/rootbench/Run2012BC_DoubleMuParked_Muons.root"

archivo = uproot.open(url_archivo)
tree = archivo["Events"]

muon_pt = tree["Muon_pt"].array(entry_stop=10)

Este array irregular de booleanos selecciona todos los muones con más de 20 GeV:

corte_particula = muon_pt > 20

muon_pt[corte_particula]
Loading...

y este array de booleanos no irregular (hecho con ak.any) selecciona todos los eventos que tienen un muón con más de 20 GeV:

corte_evento = ak.any(muon_pt > 20, axis=1)

muon_pt[corte_evento]
Loading...

Pregunta rápida: construye exactamente el mismo corte_evento usando ak.max.

Pregunta rápida: aplica ambos cortes; es decir, selecciona los muones con más de 20 GeV de los eventos que los tienen.

Sugerencia: vas a querer construir un

depurados = muon_pt[corte_particula]

intermedio, y no puedes usar la variable corte_evento tal como está.

Sugerencia: el resultado final debería ser un array irregular, igual que muon_pt, pero con menos listas y menos elementos en esas listas.

Combinatoria en Awkward Array

Las listas de longitud variable presentan más problemas que solo el rebanado y el cálculo de fórmulas array por array. A menudo queremos combinar partículas en todos los pares posibles (dentro de cada evento) para buscar cadenas de desintegración.

Pares a partir de dos arrays, pares a partir de un solo array

Awkward Array tiene funciones que generan estas combinaciones. Por ejemplo, ak.cartesian toma un producto cartesiano por evento (cuando axis=1, el valor predeterminado).

esquema del producto cartesiano
numeros = ak.Array([[1, 2, 3], [], [5, 7], [11]])
letras = ak.Array([["a", "b"], ["c"], ["d"], ["e", "f"]])

pares = ak.cartesian((numeros, letras))

Estos pares son 2-tuplas, que se parecen a registros en la forma en que se rebanan de un array: usando cadenas.

pares["0"]
Loading...
pares["1"]
Loading...

También existe ak.unzip, que extrae cada campo en un array separado (lo opuesto de ak.zip).

izquierda, derecha = ak.unzip(pares)
izquierda
Loading...
derecha
Loading...

Ten en cuenta que estos izquierda y derecha no son los numeros y letras originales: han sido duplicados y tienen la misma forma.

El producto cartesiano es equivalente a este bucle for de C++ sobre dos colecciones:

for (int i = 0; i < numeros.size(); i++) {
  for (int j = 0; j < letras.size(); j++) {
    // calcular la fórmula con numeros[i] y letras[j]
  }
}

A veces, sin embargo, queremos encontrar todos los pares dentro de una sola colección, sin repetición. Eso sería equivalente a este bucle for de C++:

for (int i = 0; i < numeros.size(); i++) {
  for (int j = i + 1; j < numeros.size(); j++) {
    // calcular la fórmula con numeros[i] y numeros[j]
  }
}

La función de Awkward para este caso es ak.combinations.

esquema de las combinaciones
pares = ak.combinations(numeros, 2)
pares

izquierda, derecha = ak.unzip(pares)

izquierda * derecha  # se alinean, así que podemos calcular fórmulas
Loading...

Aplicación a los dimuones

La búsqueda de dimuones de la lección anterior fue un poco ingenua, en el sentido de que exigíamos que existieran exactamente dos muones en cada evento y solo calculábamos la masa de esa combinación. Si hubiera un tercer muón presente porque se trata de una desintegración electrodébil compleja o porque algo se midió mal, no veríamos los otros dos muones. Podrían ser dimuones reales.

Un mejor procedimiento sería buscar todos los pares de muones en un evento y aplicar algún criterio para seleccionarlos.

En este ejemplo, juntaremos con ak.zip las variables de los muones en registros.

import uproot
import awkward as ak

url_archivo = "root://eospublic.cern.ch//eos/opendata/cms/derived-data/AOD2NanoAODOutreachTool/Run2012BC_DoubleMuParked_Muons.root"

# Si estás en Windows o no tienes XRootD instalado, puedes usar esta url en su lugar
# url_archivo = "https://root.cern/files/rootbench/Run2012BC_DoubleMuParked_Muons.root"

archivo = uproot.open(url_archivo)
tree = archivo["Events"]

arrays = tree.arrays(filter_name="/Muon_(pt|eta|phi|charge)/", entry_stop=10000)

muones = ak.zip(
    {
        "pt": arrays["Muon_pt"],
        "eta": arrays["Muon_eta"],
        "phi": arrays["Muon_phi"],
        "charge": arrays["Muon_charge"],
    }
)
arrays.type
ArrayType(RecordType([ListType(NumpyType('float32')), ListType(NumpyType('float32')), ListType(NumpyType('float32')), ListType(NumpyType('int32'))], ['Muon_pt', 'Muon_eta', 'Muon_phi', 'Muon_charge']), 10000, None)
muones.type
ArrayType(ListType(RecordType([NumpyType('float32'), NumpyType('float32'), NumpyType('float32'), NumpyType('int32')], ['pt', 'eta', 'phi', 'charge'])), 10000, None)

La diferencia entre arrays y muones es que arrays contiene listas separadas de "Muon_pt", "Muon_eta", "Muon_phi", "Muon_charge", mientras que muones contiene listas de registros con los campos "pt", "eta", "phi", "charge".

Ahora podemos calcular pares de objetos muón

pares = ak.combinations(muones, 2)

pares.type
ArrayType(ListType(RecordType([RecordType([NumpyType('float32'), NumpyType('float32'), NumpyType('float32'), NumpyType('int32')], ['pt', 'eta', 'phi', 'charge']), RecordType([NumpyType('float32'), NumpyType('float32'), NumpyType('float32'), NumpyType('int32')], ['pt', 'eta', 'phi', 'charge'])], None)), 10000, None)

y separarlos en arrays del primer muón y del segundo muón de cada par.

mu1, mu2 = ak.unzip(pares)

Pregunta rápida: ¿cómo garantizarías que todas las listas de registros en mu1 y mu2 tengan las mismas longitudes? Sugerencia: consulta ak.num y ak.all.

Dado que sí tienen las mismas longitudes, podemos usarlos en una fórmula.

import numpy as np

masa = np.sqrt(
    2 * mu1.pt * mu2.pt * (np.cosh(mu1.eta - mu2.eta) - np.cos(mu1.phi - mu2.phi))
)

Pregunta rápida: ¿cuántas masas tenemos en cada evento? ¿Cómo se compara esto con muones, mu1 y mu2?

Graficar el array irregular

Dado que esta masa es un array irregular, no se puede histogramar directamente. Los histogramas toman un conjunto de números como entrada, pero este array contiene listas.

Suponiendo que solo quieres graficar los números de las listas, puedes usar ak.flatten para aplanar un nivel de listas, o ak.ravel para aplanar todos los niveles.

import hist

hist.Hist(hist.axis.Regular(120, 0, 120, label="masa [GeV]")).fill(
    ak.ravel(masa)
).plot();
<Figure size 640x480 with 1 Axes>

Alternativamente, supongamos que quieres graficar la masa candidata máxima de cada evento, sesgándola hacia los bosones Z. ak.max es una función diferente que selecciona un elemento de cada lista, cuando se usa con axis=1.

ak.max(masa, axis=1)
Loading...

Algunos valores son None porque no hay máximo de una lista vacía. Llamar a ak.flatten con axis=0 elimina estos valores faltantes,

ak.flatten(ak.max(masa, axis=1), axis=0)
Loading...

pero eliminar de entrada las listas vacías consigue lo mismo.

ak.max(masa[ak.num(masa) > 0], axis=1)
Loading...

Ten en cuenta que aquí ak.ravel no es intercambiable con ak.flatten: aplana todos los niveles de anidamiento, pero conserva los valores faltantes, así que ak.ravel(ak.max(masa, axis=1)) todavía contiene None. Para eliminarlos, usa ak.drop_none. Vas a necesitar esto en el Ejercicio 3.