Estructura de un ndarray#

Es interesante conocer a grandes rasgos cómo se organiza la estructura interna de un objeto ndarray

El objeto contiene básicamente un buffer con datos en la memoria, más la información sobre cómo interpretar el contenido de la memoria más la información sobre cómo moverse dentro de la memoria asignada.

Aparte de estos atributos, podemos acceder desde Python (aunque rara vez es necesario)

import numpy as np
x = np.array([3, 6, -1])
print(x.data)
bytes(x.data)
<memory at 0x7fe9503bb1c0>
b'\x03\x00\x00\x00\x00\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff'

Podemos ver una representación en Python de la estructura interna accediendo a __array_interface__

x.__array_interface__
{'data': (94559977441856, False),
 'strides': None,
 'descr': [('', '<i8')],
 'typestr': '<i8',
 'shape': (3,),
 'version': 3}

Esta estructura contiene la información necesaria para interpretar el fragmento de memoria del array. En typestr tenemos la información necesaria para interpretar los datos y stride indica cuánto hay que desplazarse para acceder al siguiente elemento. None indica un array de C contiguo en memoria.

Los datos del array pueden ser compartidos por diversos objetos o incluso estar definido de manera externa, por ejemplo

# Bytes
bf = b'1234'
y = np.frombuffer(bf, dtype=np.int8)
y.base is bf
True

También hay información en el atributo flags

y.flags
  C_CONTIGUOUS : True
  F_CONTIGUOUS : True
  OWNDATA : False
  WRITEABLE : False
  ALIGNED : True
  WRITEBACKIFCOPY : False

La estructura nos indica que los datos no pertenecen al array

Otro ejemplo, con una sección:

x = np.ones((4,4))
y = x[1:3,0:2]
y.flags
  C_CONTIGUOUS : False
  F_CONTIGUOUS : False
  OWNDATA : False
  WRITEABLE : True
  ALIGNED : True
  WRITEBACKIFCOPY : False

Aquí tenemos que los datos no pertenecen a y (OWNDATA : False), pero que podemos escribir en la variable y (WRITEABLE : True); además los cambios de y se propagan a x.

Atributos y métodos del array#

Hemos visto algunos atributos, aunque en las manipulaciones normales rara vez se usan. Veremos ahora atributos más interesantes. Para ello definimos un array de tres dimensiones. Los tres primeros atributos son el ńumero de dimensiones ndim, la forma shape y el tamaño (número de elementos) size.

x3 = np.ones((3, 4, 5))
print('x3.ndim=', x3.ndim)
print('x3.shape=', x3.shape)
print('x3.size=', x3.size)
x3.ndim= 3
x3.shape= (3, 4, 5)
x3.size= 60

Otro atributo importante es dtype, del que hablaremos en la siguiente sección.

Descriptores de datos#

Hemos visto que numpy utiliza una estructura dtype para describir los datos de un array. Esta estructura contiene la clase de los datos type, el tamaño del bloque de datos en bytes itemsize, el orden de los bytes (big-endian >, little-endian < , el del sistema = o no aplicable |) en byteorder.

tp = np.dtype(int)
tp = np.dtype('uint16')
print('type', tp.type)
print('itemsize', tp.itemsize)
print('byteorder', tp.byteorder)
type <class 'numpy.uint16'>
itemsize 2
byteorder =

La lista de tipos disponibles puede encontrarse en la documentacion de Numpy

Conversión de tipos#

Se pueden realizar conversiones de tipo manualmente (con el método astype) automáticamente en ufuncs y en asignaciones. El casting se basa en los tipos de datos de los arrays, no en su contenido.

Veamos algunos ejemplos

x = np.array([1, 2, 3, 4], dtype=np.float32)
print(x)
y = x.astype(np.int8)
print(repr(y))
[1. 2. 3. 4.]
array([1, 2, 3, 4], dtype=int8)

En una operación de suma se está usando internamente ufuncs. Se realiza conversión de tipos basado en el tipo del array.

Para ver las reglas en detalle, de nuevo podemos acudir a la documentación de numpy

Vistas#

En las conversiones de datos, cambiamos los datos existentes a un nuevo tipo. En una vista, cambiamos el dtype sin cambiar los datos subyacentes.

Por ejemplo, un array con 4 uint8 puede reinterpretarse como 4 int8 o también 2 int16 o 1 int32 o 1 float32.

Podemos forzar un cambio de dtype:

x = np.array([1, 2, 3, 4], dtype=np.uint8)
x.dtype = '=i2'
print(repr(x))
array([ 513, 1027], dtype=int16)

O bien crear una nueva vista

x = np.array([1, 2, 3, 4], dtype=np.uint8)
y = x.view('=i4')
print(repr(y))
y.flags
array([67305985], dtype=int32)
  C_CONTIGUOUS : True
  F_CONTIGUOUS : True
  OWNDATA : False
  WRITEABLE : True
  ALIGNED : True
  WRITEBACKIFCOPY : False

Cómo funcionan los índices: strides#

¿Cómo podemos acceder a los elementos del array?

x = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=np.int8)
x.tobytes('A')
b'\x01\x02\x03\x04\x05\x06\x07\x08\t'

¿En qué byte del buffer interno de x empieza x[1,2]?

Se almacena en un atributo llamado strides. Indica cuántos bytes hay que moverse para alcanzar un cierto elemento.

print(x.strides)
byte_offset = 3*1 + 1*2
print(x.flat[byte_offset])
print(x[1,2])
(3, 1)
6
6

strides combina el tamaño del array dado con shape con el tamaño del tipo de dato en dtype.

Parte de las operaciones de indexación y algunas otras como la trasposición se puede obtener simplemente cambiando strides

x = np.array([1, 2, 3, 4, 5, 6], dtype=np.int32)
print('x.strides=',x.strides)
y = x[::-1]
print('y=',y)
print('y.strides=',y.strides)
x.strides= (4,)
y= [6 5 4 3 2 1]
y.strides= (-4,)
x = np.zeros((10,10,10), dtype=np.int32)
print('x.strides=',x.strides)
y = x.T
print('y.strides=',y.strides)
x.strides= (400, 40, 4)
y.strides= (4, 40, 400)

Se puede manipular los valores de stride de un array para conseguir manipulaciones avanzadas, como extraer diagonales, repetir elementos, etc.

Pueden verse ejemplos aquí