From 21887927a252dd3fa774f020292b17ffebba3747 Mon Sep 17 00:00:00 2001 From: Stijn de Gooijer Date: Fri, 17 May 2024 22:47:50 +0200 Subject: [PATCH 1/8] Improve handling of chunked viewable Series --- py-polars/src/series/export.rs | 14 +++++-- py-polars/src/to_numpy.rs | 71 ++++++++++++++++++++++++---------- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/py-polars/src/series/export.rs b/py-polars/src/series/export.rs index 4903b3df093e..d6657a7717fc 100644 --- a/py-polars/src/series/export.rs +++ b/py-polars/src/series/export.rs @@ -174,14 +174,22 @@ impl PySeries { return series_to_numpy_with_copy(py, &self.series); } - if let Some(mut arr) = series_to_numpy_view(py, &self.series, false) { - if writable { + if let Some(mut arr) = series_to_numpy_view(py, &self.series, false, allow_copy) { + if writable + && !arr + .getattr(py, intern!(py, "flags")) + .unwrap() + .getattr(py, intern!(py, "writeable")) + .unwrap() + .extract::(py) + .unwrap() + { if !allow_copy { return Err(PyValueError::new_err( "cannot return a zero-copy writable array", )); } - arr = arr.call_method0(py, intern!(py, "copy"))?; + arr = arr.call_method0(py, intern!(py, "copy")).unwrap(); } return Ok(arr); } diff --git a/py-polars/src/to_numpy.rs b/py-polars/src/to_numpy.rs index 6c086b0f24c5..e4aade9cf7cb 100644 --- a/py-polars/src/to_numpy.rs +++ b/py-polars/src/to_numpy.rs @@ -58,34 +58,56 @@ impl PySeries { /// WARNING: The resulting view will show the underlying value for nulls, /// which may be any value. The caller is responsible for handling nulls /// appropriately. - #[allow(clippy::wrong_self_convention)] pub fn to_numpy_view(&self, py: Python) -> Option { - series_to_numpy_view(py, &self.series, true) + series_to_numpy_view(py, &self.series, true, false) } } -pub(crate) fn series_to_numpy_view(py: Python, s: &Series, allow_nulls: bool) -> Option { - // NumPy arrays are always contiguous - if s.n_chunks() > 1 { - return None; - } +pub(crate) fn series_to_numpy_view( + py: Python, + s: &Series, + allow_nulls: bool, + allow_rechunk: bool, +) -> Option { if !allow_nulls && s.null_count() > 0 { return None; } + + let is_chunked = s.n_chunks() > 1; + let s_owned = if is_chunked { + // NumPy arrays are always contiguous + if !allow_rechunk { + return None; + } else { + s.rechunk() + } + } else { + s.clone() + }; + let view = match s.dtype() { - dt if dt.is_numeric() => numeric_series_to_numpy_view(py, s), - DataType::Datetime(_, _) | DataType::Duration(_) => temporal_series_to_numpy_view(py, s), - DataType::Array(_, _) => array_series_to_numpy_view(py, s, allow_nulls)?, + dt if dt.is_numeric() => numeric_series_to_numpy_view(py, s_owned, is_chunked), + DataType::Datetime(_, _) | DataType::Duration(_) => { + temporal_series_to_numpy_view(py, s_owned, is_chunked) + }, + DataType::Array(_, _) => { + array_series_to_numpy_view(py, &s_owned, allow_nulls, allow_rechunk)? + }, _ => return None, }; Some(view) } -fn numeric_series_to_numpy_view(py: Python, s: &Series) -> PyObject { +fn numeric_series_to_numpy_view(py: Python, s: Series, writable: bool) -> PyObject { let dims = [s.len()].into_dimension(); - let owner = PySeries::from(s.clone()).into_py(py); // Keep the Series memory alive. with_match_physical_numeric_polars_type!(s.dtype(), |$T| { let np_dtype = <$T as PolarsNumericType>::Native::get_dtype_bound(py); let ca: &ChunkedArray<$T> = s.unpack::<$T>().unwrap(); + let flags = if writable { + flags::NPY_ARRAY_FARRAY + } else { + flags::NPY_ARRAY_FARRAY_RO + }; + let slice = ca.data_views().next().unwrap(); unsafe { @@ -93,30 +115,34 @@ fn numeric_series_to_numpy_view(py: Python, s: &Series) -> PyObject { py, np_dtype, dims, - flags::NPY_ARRAY_FARRAY_RO, + flags, slice.as_ptr() as _, - owner, + PySeries::from(s).into_py(py), // Keep the Series memory alive., ) } }) } -fn temporal_series_to_numpy_view(py: Python, s: &Series) -> PyObject { +fn temporal_series_to_numpy_view(py: Python, s: Series, writable: bool) -> PyObject { let np_dtype = polars_dtype_to_np_temporal_dtype(py, s.dtype()); let phys = s.to_physical_repr(); let ca = phys.i64().unwrap(); let slice = ca.data_views().next().unwrap(); let dims = [s.len()].into_dimension(); - let owner = PySeries::from(s.clone()).into_py(py); // Keep the Series memory alive. + let flags = if writable { + flags::NPY_ARRAY_FARRAY + } else { + flags::NPY_ARRAY_FARRAY_RO + }; unsafe { create_borrowed_np_array::<_>( py, np_dtype, dims, - flags::NPY_ARRAY_FARRAY_RO, + flags, slice.as_ptr() as _, - owner, + PySeries::from(s).into_py(py), // Keep the Series memory alive., ) } } @@ -148,10 +174,15 @@ fn polars_dtype_to_np_temporal_dtype<'a>( _ => panic!("only Datetime/Duration inputs supported, got {}", dtype), } } -fn array_series_to_numpy_view(py: Python, s: &Series, allow_nulls: bool) -> Option { +fn array_series_to_numpy_view( + py: Python, + s: &Series, + allow_nulls: bool, + allow_rechunk: bool, +) -> Option { let ca = s.array().unwrap(); let s_inner = ca.get_inner(); - let np_array_flat = series_to_numpy_view(py, &s_inner, allow_nulls)?; + let np_array_flat = series_to_numpy_view(py, &s_inner, allow_nulls, allow_rechunk)?; // Reshape to the original shape. let DataType::Array(_, width) = s.dtype() else { From d45d505be0c2f08638e90f6af4d73b9c06e69bb8 Mon Sep 17 00:00:00 2001 From: Stijn de Gooijer Date: Fri, 17 May 2024 22:48:01 +0200 Subject: [PATCH 2/8] Update test --- py-polars/tests/unit/interop/numpy/test_to_numpy_series.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py b/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py index 3ecd0cf4963a..ab7580923ff3 100644 --- a/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py +++ b/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py @@ -308,8 +308,14 @@ def test_to_numpy_chunked() -> None: assert result.tolist() == s.to_list() assert result.dtype == np.int64 + assert result.flags.writeable is True assert_allow_copy_false_raises(s) + # Check that writing to the array doesn't change the original data + result[0] = 10 + assert result.tolist() == [10, 2, 3, 4] + assert s.to_list() == [1, 2, 3, 4] + def test_zero_copy_only_deprecated() -> None: values = [1, 2] From 84920df959d43623b595ebf5aecea85b439413b8 Mon Sep 17 00:00:00 2001 From: Stijn de Gooijer Date: Fri, 17 May 2024 23:44:44 +0200 Subject: [PATCH 3/8] Add coverage for temporal branch --- .../unit/interop/numpy/test_to_numpy_series.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py b/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py index ab7580923ff3..ff11d6f5c0af 100644 --- a/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py +++ b/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py @@ -317,6 +317,19 @@ def test_to_numpy_chunked() -> None: assert s.to_list() == [1, 2, 3, 4] +def test_to_numpy_chunked_temporal() -> None: + s1 = pl.Series([datetime(2020, 1, 1), datetime(2021, 1, 1)]) + s2 = pl.Series([datetime(2022, 1, 1), datetime(2023, 1, 1)]) + s = pl.concat([s1, s2], rechunk=False) + + result = s.to_numpy(use_pyarrow=False) + + assert result.tolist() == s.to_list() + assert result.dtype == np.dtype("datetime64[us]") + assert result.flags.writeable is True + assert_allow_copy_false_raises(s) + + def test_zero_copy_only_deprecated() -> None: values = [1, 2] s = pl.Series([1, 2]) From 8243b89572d53caefa15c7267f3ff33dc69dfbd3 Mon Sep 17 00:00:00 2001 From: Stijn de Gooijer Date: Sat, 18 May 2024 00:27:57 +0200 Subject: [PATCH 4/8] Add benchmark tests for numpy interop --- py-polars/tests/benchmark/interop/__init__.py | 1 + .../tests/benchmark/interop/test_numpy.py | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 py-polars/tests/benchmark/interop/__init__.py create mode 100644 py-polars/tests/benchmark/interop/test_numpy.py diff --git a/py-polars/tests/benchmark/interop/__init__.py b/py-polars/tests/benchmark/interop/__init__.py new file mode 100644 index 000000000000..2b2fa00648fd --- /dev/null +++ b/py-polars/tests/benchmark/interop/__init__.py @@ -0,0 +1 @@ +"""Benchmark tests for conversions from/to other data formats.""" diff --git a/py-polars/tests/benchmark/interop/test_numpy.py b/py-polars/tests/benchmark/interop/test_numpy.py new file mode 100644 index 000000000000..f5df983c1bec --- /dev/null +++ b/py-polars/tests/benchmark/interop/test_numpy.py @@ -0,0 +1,51 @@ +"""Benchmark tests for conversions from/to NumPy.""" + +from typing import Any + +import numpy as np +import pytest + +import polars as pl + +pytestmark = pytest.mark.benchmark() + + +@pytest.fixture(scope="module") +def floats_array() -> np.ndarray[Any, Any]: + n_rows = 10_000 + return np.random.randn(n_rows) + + +@pytest.fixture() +def floats(floats_array: np.ndarray[Any, Any]) -> pl.Series: + return pl.Series(floats_array) + + +@pytest.fixture() +def floats_with_nulls(floats: pl.Series) -> pl.Series: + null_probability = 0.1 + validity = pl.Series(np.random.uniform(size=floats.len())) > null_probability + return pl.select(pl.when(validity).then(floats)).to_series() + + +@pytest.fixture() +def floats_chunked(floats_array: np.ndarray[Any, Any]) -> pl.Series: + n_chunks = 5 + chunk_len = len(floats_array) // n_chunks + chunks = [ + floats_array[i * chunk_len : (i + 1) * chunk_len] for i in range(n_chunks) + ] + chunks_copy = [pl.Series(c.copy()) for c in chunks] + return pl.concat(chunks_copy, rechunk=False) + + +def test_to_numpy_series_zero_copy(floats: pl.Series) -> None: + floats.to_numpy(use_pyarrow=False) + + +def test_to_numpy_series_with_nulls(floats_with_nulls: pl.Series) -> None: + floats_with_nulls.to_numpy(use_pyarrow=False) + + +def test_to_numpy_series_chunked(floats_chunked: pl.Series) -> None: + floats_chunked.to_numpy(use_pyarrow=False) From 241590cd70d0735774a64d0380036524fd683ab2 Mon Sep 17 00:00:00 2001 From: Stijn de Gooijer Date: Sat, 18 May 2024 00:29:49 +0200 Subject: [PATCH 5/8] Restore question mark --- py-polars/src/series/export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py-polars/src/series/export.rs b/py-polars/src/series/export.rs index d6657a7717fc..2667023fcf3e 100644 --- a/py-polars/src/series/export.rs +++ b/py-polars/src/series/export.rs @@ -189,7 +189,7 @@ impl PySeries { "cannot return a zero-copy writable array", )); } - arr = arr.call_method0(py, intern!(py, "copy")).unwrap(); + arr = arr.call_method0(py, intern!(py, "copy"))?; } return Ok(arr); } From c55234a52fa517bb001afa4d1d2222118be81de7 Mon Sep 17 00:00:00 2001 From: Stijn de Gooijer Date: Sat, 18 May 2024 00:38:57 +0200 Subject: [PATCH 6/8] Typing --- py-polars/tests/benchmark/interop/test_numpy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/py-polars/tests/benchmark/interop/test_numpy.py b/py-polars/tests/benchmark/interop/test_numpy.py index f5df983c1bec..cd81b382ef49 100644 --- a/py-polars/tests/benchmark/interop/test_numpy.py +++ b/py-polars/tests/benchmark/interop/test_numpy.py @@ -1,5 +1,7 @@ """Benchmark tests for conversions from/to NumPy.""" +from __future__ import annotations + from typing import Any import numpy as np From f75b9a76b078a757ab585e9131c410430b2b5524 Mon Sep 17 00:00:00 2001 From: Stijn de Gooijer Date: Sat, 18 May 2024 14:12:35 +0200 Subject: [PATCH 7/8] Improve behavior for arrays / do not rechunk unnecssarily --- crates/polars-core/src/datatypes/dtype.rs | 6 +- py-polars/src/to_numpy.rs | 89 ++++++++++++------- .../interop/numpy/test_to_numpy_series.py | 8 +- 3 files changed, 67 insertions(+), 36 deletions(-) diff --git a/crates/polars-core/src/datatypes/dtype.rs b/crates/polars-core/src/datatypes/dtype.rs index ee3197384fd5..8751cb644693 100644 --- a/crates/polars-core/src/datatypes/dtype.rs +++ b/crates/polars-core/src/datatypes/dtype.rs @@ -236,17 +236,17 @@ impl DataType { self.is_float() || self.is_integer() } - /// Check if this [`DataType`] is a boolean + /// Check if this [`DataType`] is a boolean. pub fn is_bool(&self) -> bool { matches!(self, DataType::Boolean) } - /// Check if this [`DataType`] is a list + /// Check if this [`DataType`] is a list. pub fn is_list(&self) -> bool { matches!(self, DataType::List(_)) } - /// Check if this [`DataType`] is a array + /// Check if this [`DataType`] is an array. pub fn is_array(&self) -> bool { #[cfg(feature = "dtype-array")] { diff --git a/py-polars/src/to_numpy.rs b/py-polars/src/to_numpy.rs index e4aade9cf7cb..5068858d471b 100644 --- a/py-polars/src/to_numpy.rs +++ b/py-polars/src/to_numpy.rs @@ -69,34 +69,67 @@ pub(crate) fn series_to_numpy_view( allow_nulls: bool, allow_rechunk: bool, ) -> Option { - if !allow_nulls && s.null_count() > 0 { + if !supports_view(s.dtype()) { return None; } + if !allow_nulls && has_nulls(s) { + return None; + } + let (s_owned, writable) = handle_chunks(s, allow_rechunk)?; - let is_chunked = s.n_chunks() > 1; - let s_owned = if is_chunked { - // NumPy arrays are always contiguous - if !allow_rechunk { - return None; - } else { - s.rechunk() - } + Some(series_to_numpy_view_recursive(py, s_owned, writable)) +} +/// Returns whether the data type supports creating a NumPy view. +fn supports_view(dtype: &DataType) -> bool { + match dtype { + dt if dt.is_numeric() => true, + DataType::Datetime(_, _) | DataType::Duration(_) => true, + DataType::Array(inner, _) => supports_view(inner.as_ref()), + _ => false, + } +} +/// Returns whether the Series contains nulls at any level of nesting. +/// +/// Of the nested types, only Array types are handled since only those are relevant for NumPy views. +fn has_nulls(s: &Series) -> bool { + if s.null_count() > 0 { + true + } else if s.dtype().is_array() { + let ca = s.array().unwrap(); + let s_inner = ca.get_inner(); + has_nulls(&s_inner) } else { - s.clone() - }; + false + } +} +/// Rechunk the Series if required. +/// +/// NumPy arrays are always contiguous, so we may have to rechunk before creating a view. +/// If we do so, we can flag the resulting array as writable. +fn handle_chunks(s: &Series, allow_rechunk: bool) -> Option<(Series, bool)> { + let is_chunked = s.n_chunks() > 1; + match (is_chunked, allow_rechunk) { + (true, false) => None, + (true, true) => Some((s.rechunk(), true)), + (false, _) => Some((s.clone(), false)), + } +} - let view = match s.dtype() { - dt if dt.is_numeric() => numeric_series_to_numpy_view(py, s_owned, is_chunked), +/// Create a NumPy view of the given Series. +/// +/// This function is called after verifying that the Series consists of a single chunk. +fn series_to_numpy_view_recursive(py: Python, s: Series, writable: bool) -> PyObject { + debug_assert!(s.n_chunks() == 1); + match s.dtype() { + dt if dt.is_numeric() => numeric_series_to_numpy_view(py, s, writable), DataType::Datetime(_, _) | DataType::Duration(_) => { - temporal_series_to_numpy_view(py, s_owned, is_chunked) - }, - DataType::Array(_, _) => { - array_series_to_numpy_view(py, &s_owned, allow_nulls, allow_rechunk)? + temporal_series_to_numpy_view(py, s, writable) }, - _ => return None, - }; - Some(view) + DataType::Array(_, _) => array_series_to_numpy_view(py, &s, writable), + _ => panic!("invalid data type"), + } } +/// Create a NumPy view of a numeric Series. fn numeric_series_to_numpy_view(py: Python, s: Series, writable: bool) -> PyObject { let dims = [s.len()].into_dimension(); with_match_physical_numeric_polars_type!(s.dtype(), |$T| { @@ -122,6 +155,7 @@ fn numeric_series_to_numpy_view(py: Python, s: Series, writable: bool) -> PyObje } }) } +/// Create a NumPy view of a Datetime or Duration Series. fn temporal_series_to_numpy_view(py: Python, s: Series, writable: bool) -> PyObject { let np_dtype = polars_dtype_to_np_temporal_dtype(py, s.dtype()); @@ -174,22 +208,17 @@ fn polars_dtype_to_np_temporal_dtype<'a>( _ => panic!("only Datetime/Duration inputs supported, got {}", dtype), } } -fn array_series_to_numpy_view( - py: Python, - s: &Series, - allow_nulls: bool, - allow_rechunk: bool, -) -> Option { +/// Create a NumPy view of an Array Series. +fn array_series_to_numpy_view(py: Python, s: &Series, writable: bool) -> PyObject { let ca = s.array().unwrap(); let s_inner = ca.get_inner(); - let np_array_flat = series_to_numpy_view(py, &s_inner, allow_nulls, allow_rechunk)?; + let np_array_flat = series_to_numpy_view_recursive(py, s_inner, writable); // Reshape to the original shape. let DataType::Array(_, width) = s.dtype() else { unreachable!() }; - let view = reshape_numpy_array(py, np_array_flat, ca.len(), *width); - Some(view) + reshape_numpy_array(py, np_array_flat, ca.len(), *width) } /// Reshape the first dimension of a NumPy array to the given height and width. pub(crate) fn reshape_numpy_array( @@ -205,7 +234,7 @@ pub(crate) fn reshape_numpy_array( .unwrap(); if shape.len() == 1 { - // In this case we can avoid allocating a Vec. + // In this case, we can avoid allocating a Vec. let new_shape = (height, width); arr.call_method1(py, intern!(py, "reshape"), new_shape) .unwrap() diff --git a/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py b/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py index ff11d6f5c0af..e167be31c148 100644 --- a/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py +++ b/py-polars/tests/unit/interop/numpy/test_to_numpy_series.py @@ -317,15 +317,17 @@ def test_to_numpy_chunked() -> None: assert s.to_list() == [1, 2, 3, 4] -def test_to_numpy_chunked_temporal() -> None: - s1 = pl.Series([datetime(2020, 1, 1), datetime(2021, 1, 1)]) - s2 = pl.Series([datetime(2022, 1, 1), datetime(2023, 1, 1)]) +def test_to_numpy_chunked_temporal_nested() -> None: + dtype = pl.Array(pl.Datetime("us"), 1) + s1 = pl.Series([[datetime(2020, 1, 1)], [datetime(2021, 1, 1)]], dtype=dtype) + s2 = pl.Series([[datetime(2022, 1, 1)], [datetime(2023, 1, 1)]], dtype=dtype) s = pl.concat([s1, s2], rechunk=False) result = s.to_numpy(use_pyarrow=False) assert result.tolist() == s.to_list() assert result.dtype == np.dtype("datetime64[us]") + assert result.shape == (4, 1) assert result.flags.writeable is True assert_allow_copy_false_raises(s) From 319d255139145785dce6761280c34ed6f7f7732e Mon Sep 17 00:00:00 2001 From: Stijn de Gooijer Date: Sat, 18 May 2024 14:32:05 +0200 Subject: [PATCH 8/8] Return writable flag --- py-polars/src/series/export.rs | 16 +++++----------- py-polars/src/to_numpy.rs | 17 +++++++++-------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/py-polars/src/series/export.rs b/py-polars/src/series/export.rs index 2667023fcf3e..0b69e8adff3e 100644 --- a/py-polars/src/series/export.rs +++ b/py-polars/src/series/export.rs @@ -9,7 +9,7 @@ use pyo3::types::PyList; use crate::conversion::chunked_array::{decimal_to_pyobject_iter, time_to_pyobject_iter}; use crate::error::PyPolarsErr; use crate::prelude::*; -use crate::to_numpy::{reshape_numpy_array, series_to_numpy_view}; +use crate::to_numpy::{reshape_numpy_array, try_series_to_numpy_view}; use crate::{arrow_interop, raise_err, PySeries}; #[pymethods] @@ -174,16 +174,10 @@ impl PySeries { return series_to_numpy_with_copy(py, &self.series); } - if let Some(mut arr) = series_to_numpy_view(py, &self.series, false, allow_copy) { - if writable - && !arr - .getattr(py, intern!(py, "flags")) - .unwrap() - .getattr(py, intern!(py, "writeable")) - .unwrap() - .extract::(py) - .unwrap() - { + if let Some((mut arr, writable_flag)) = + try_series_to_numpy_view(py, &self.series, false, allow_copy) + { + if writable && !writable_flag { if !allow_copy { return Err(PyValueError::new_err( "cannot return a zero-copy writable array", diff --git a/py-polars/src/to_numpy.rs b/py-polars/src/to_numpy.rs index 5068858d471b..3a18bf5a5679 100644 --- a/py-polars/src/to_numpy.rs +++ b/py-polars/src/to_numpy.rs @@ -59,25 +59,28 @@ impl PySeries { /// which may be any value. The caller is responsible for handling nulls /// appropriately. pub fn to_numpy_view(&self, py: Python) -> Option { - series_to_numpy_view(py, &self.series, true, false) + let (view, _) = try_series_to_numpy_view(py, &self.series, true, false)?; + Some(view) } } -pub(crate) fn series_to_numpy_view( +/// Create a NumPy view of the given Series. +pub(crate) fn try_series_to_numpy_view( py: Python, s: &Series, allow_nulls: bool, allow_rechunk: bool, -) -> Option { +) -> Option<(PyObject, bool)> { if !supports_view(s.dtype()) { return None; } if !allow_nulls && has_nulls(s) { return None; } - let (s_owned, writable) = handle_chunks(s, allow_rechunk)?; + let (s_owned, writable_flag) = handle_chunks(s, allow_rechunk)?; - Some(series_to_numpy_view_recursive(py, s_owned, writable)) + let array = series_to_numpy_view_recursive(py, s_owned, writable_flag); + Some((array, writable_flag)) } /// Returns whether the data type supports creating a NumPy view. fn supports_view(dtype: &DataType) -> bool { @@ -115,9 +118,7 @@ fn handle_chunks(s: &Series, allow_rechunk: bool) -> Option<(Series, bool)> { } } -/// Create a NumPy view of the given Series. -/// -/// This function is called after verifying that the Series consists of a single chunk. +/// Create a NumPy view of the given Series without checking for data types, chunks, or nulls. fn series_to_numpy_view_recursive(py: Python, s: Series, writable: bool) -> PyObject { debug_assert!(s.n_chunks() == 1); match s.dtype() {