Saltar al contenido principal

Corrección de matriz con orden F

Planteamiento del problema

La codificación/decodificación Protobuf de matrices NumPy falla si están en orden F

Pruebas

from mcbackend.npproto.utils import ndarray_to_numpy, ndarray_from_numpy

arrC = numpy.array([
[1,2,3],
[4,5,6],
], order="C")

arrF = numpy.array([
[1,2,3],
[4,5,6],
], order="F")

assert arrC.strides != arrF.strides

numpy.testing.assert_array_equal(
arrC,
ndarray_to_numpy(ndarray_from_numpy(arrC)),
)
numpy.testing.assert_array_equal(
arrF,
ndarray_to_numpy(ndarray_from_numpy(arrF)),
)

Diferencia de código

mcbackend/__init__.py

diff --git a/mcbackend/__init__.py b/mcbackend/__init__.py
index 35ef7b9..c6a43eb 100644
--- a/mcbackend/__init__.py
+++ b/mcbackend/__init__.py
@@ -1,23 +1,23 @@
"""
A framework agnostic implementation for storage of MCMC draws.
"""
from .backends.numpy import NumPyBackend
from .core import Backend, Chain, Run
from .meta import ChainMeta, Coordinate, DataVariable, ExtendedValue, RunMeta, Variable

# Backends
try:
from .backends import clickhouse
from .backends.clickhouse import ClickHouseBackend
except ModuleNotFoundError:
pass

# Adapters
try:
from .adapters import pymc
from .adapters.pymc import TraceBackend
except ModuleNotFoundError:
pass


-__version__ = "0.2.3"
+__version__ = "0.2.4"

mcbackend/npproto/utils.py

diff --git a/mcbackend/npproto/utils.py b/mcbackend/npproto/utils.py
index 27bc98c..8ae5396 100644
--- a/mcbackend/npproto/utils.py
+++ b/mcbackend/npproto/utils.py
@@ -1,40 +1,46 @@
"""
Helper functions such as converters between ``ndarray`` and ``Ndarray``.
"""
import numpy

from . import Ndarray


def ndarray_from_numpy(arr: numpy.ndarray) -> Ndarray:
dt = str(arr.dtype)
if "datetime64" in dt:
# datetime64 doesn't support the buffer protocol.
# See https://github.com/numpy/numpy/issues/4983
# This is a hack that automatically encodes it as int64.
arr = arr.astype("int64")
+ # With non-C-ordered arrays (e.g. Fortran-ordered) the underlying buffer
+ # does not match the strides of the original array anymore, because
+ # ``bytes(arr.data)`` always returns the data in C-order.
+ # Therefore, the array is made C-contiguous before extracting data and strides.
+ if not arr.flags.c_contiguous:
+ arr = numpy.ascontiguousarray(arr)
return Ndarray(
shape=list(arr.shape),
dtype=dt,
data=bytes(arr.data),
strides=list(arr.strides),
)


def ndarray_to_numpy(nda: Ndarray) -> numpy.ndarray:
arr: numpy.ndarray
if "datetime64" in nda.dtype:
# Backwards conversion: The data was stored as int64.
arr = numpy.ndarray(
buffer=nda.data,
shape=nda.shape,
dtype="int64",
strides=nda.strides,
).astype(nda.dtype)
else:
arr = numpy.ndarray(
buffer=nda.data,
shape=nda.shape,
dtype=numpy.dtype(nda.dtype),
strides=nda.strides,
)

mcbackend/test_npproto.py

diff --git a/mcbackend/test_npproto.py b/mcbackend/test_npproto.py
index fc88d29..fd88e7c 100644
--- a/mcbackend/test_npproto.py
+++ b/mcbackend/test_npproto.py
@@ -1,45 +1,48 @@
from datetime import datetime

import numpy
import pytest

from mcbackend import npproto
from mcbackend.npproto import utils


class TestUtils:
@pytest.mark.parametrize(
"arr",
[
numpy.arange(5),
numpy.random.uniform(size=(2, 3)),
numpy.array(5),
numpy.array(["hello", "world"]),
numpy.array([datetime(2020, 3, 4, 5, 6, 7, 8), datetime(2020, 3, 4, 5, 6, 7, 9)]),
numpy.array(
[datetime(2020, 3, 4, 5, 6, 7, 8), datetime(2020, 3, 4, 5, 6, 7, 9)],
dtype="datetime64",
),
numpy.array([(1, 2), (3, 2, 1)], dtype=object),
+ numpy.array([[1, 2, 3], [4, 5, 6]], order="C"),
+ numpy.array([[1, 2, 3], [4, 5, 6]], order="F"),
+ numpy.arange(12).reshape(3, 4)[::2, ::2],
],
)
def test_conversion(self, arr: numpy.ndarray):
nda = utils.ndarray_from_numpy(arr)
enc = bytes(nda)
dec = npproto.Ndarray().parse(enc)
assert isinstance(dec.data, bytes)
result = utils.ndarray_to_numpy(dec)
numpy.testing.assert_array_equal(result, arr)
pass

@pytest.mark.parametrize("shape", [(5,), (2, 3), (2, 3, 5), (5, 2, 1, 7)])
@pytest.mark.parametrize("order", "CF")
def test_byteorders(self, shape, order):
arr = numpy.arange(numpy.prod(shape)).reshape(shape, order=order)

nda = utils.ndarray_from_numpy(arr)
assert nda.order == "CF"[arr.flags.f_contiguous]

dec = utils.ndarray_to_numpy(nda)
numpy.testing.assert_array_equal(arr, dec)
pass

Mensaje de confirmación (Commit)

Corregir la codificación de matrices NumPy no contiguas

`bytes(arr.data)` siempre devuelve el contenido del búfer en orden C, pero
codificar una matriz en orden Fortran (u otra que no sea contigua en C) provoca
una conversión de ida y vuelta corrupta a través de `ndarray_from_numpy` y `ndarray_to_numpy`.
La matriz se desordena cuando se almacenan sus pasos (strides) originales.

Utilice `numpy.ascontiguousarray` para hacer que la matriz sea contigua en C antes de
extraer los pasos y los datos. Ahora el búfer y los pasos se mantienen consistentes.

Cierra #2124

Nota: La versión `0.2.4` agrega pruebas de ida y vuelta para matrices en orden C, en orden F
y no contiguas.

Pull request

Resumen

La conversión de ida y vuelta con Protobuf de matrices NumPy corrompe silenciosamente los datos cuando la matriz de origen no es contigua en C (por ejemplo, está en orden Fortran). Esta corrección asegura que todos los diseños de memoria sobrevivan a un ciclo de codificación/decodificación.

arrF = numpy.array([[1, 2, 3], [4, 5, 6]], order="F")
numpy.testing.assert_array_equal(
arrF,
ndarray_to_numpy(ndarray_from_numpy(arrF)), # fallaba antes de esta corrección
)

Causa raíz

La ruta de codificación en ndarray_from_numpy almacena tanto el búfer en bruto como los pasos (strides) de la matriz de entrada. El problema es una discrepancia entre los dos:

  1. Codificación: bytes(arr.data) copia el búfer de memoria subyacente. Para matrices no contiguas en C, esta copia se reordena silenciosamente en orden C.
  2. Pasos almacenados tal cual: Los strides acompañantes se guardan en su formato original (no C), porque no se les aplica ninguna reordenación.
  3. Decodificación: numpy.ndarray(buffer=..., strides=...) reconstruye la matriz interpretando el búfer en orden C con los pasos originales, una discrepancia que produce una matriz desordenada.

Corrección

Llamar a numpy.ascontiguousarray antes de extraer el búfer y los pasos, para que ambos reflejen el mismo diseño de memoria:

  • mcbackend/npproto/utils.py: Hacer que la matriz sea contigua en C antes de que se extraigan data y strides. Los pasos almacenados ahora coinciden siempre con el diseño del búfer almacenado. Las matrices que ya son contiguas en C no se ven afectadas.
  • mcbackend/test_npproto.py: Agregar casos de prueba de ida y vuelta explícitos para matrices en orden C, en orden F y no contiguas (con particiones).

Pruebas

La matriz de prueba en TestUtils.test_conversion ahora cubre:

  • numpy.array([[1, 2, 3], [4, 5, 6]], order="C")
  • numpy.array([[1, 2, 3], [4, 5, 6]], order="F")
  • numpy.arange(12).reshape(3, 4)[::2, ::2] (vista no contigua)

Todos los casos de dtype existentes (int, float, str, datetime, object) continúan pasando sin cambios.

Notas

  • Actualiza la versión a 0.2.4.
  • ndarray_to_numpy no tiene cambios; no se necesitan cambios en el lado de la decodificación.

Cómo probar

  1. Ejecutar pytest mcbackend/test_npproto.py. Los tres nuevos casos parametrizados (orden C, orden F, partición no contigua) deberían pasar.
  2. Verificar que todos los casos de dtype preexistentes (int, float, str, datetime, object) sigan pasando sin cambios.