binsparse 0.1.3__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {binsparse-0.1.3 → binsparse-0.1.4}/PKG-INFO +7 -1
  2. {binsparse-0.1.3 → binsparse-0.1.4}/README.md +6 -0
  3. {binsparse-0.1.3 → binsparse-0.1.4}/pyproject.toml +1 -1
  4. {binsparse-0.1.3 → binsparse-0.1.4}/src/binsparse/conversions/scipy.py +4 -1
  5. {binsparse-0.1.3 → binsparse-0.1.4}/src/binsparse/conversions/sparse.py +17 -1
  6. {binsparse-0.1.3 → binsparse-0.1.4}/src/binsparse/conversions/torch.py +15 -7
  7. {binsparse-0.1.3 → binsparse-0.1.4}/src/tests/test_conversion_scipy.py +21 -4
  8. {binsparse-0.1.3 → binsparse-0.1.4}/src/tests/test_conversion_sparse.py +33 -0
  9. {binsparse-0.1.3 → binsparse-0.1.4}/src/tests/test_conversion_torch.py +52 -11
  10. {binsparse-0.1.3 → binsparse-0.1.4}/.github/workflows/ci.yml +0 -0
  11. {binsparse-0.1.3 → binsparse-0.1.4}/.github/workflows/publish.yml +0 -0
  12. {binsparse-0.1.3 → binsparse-0.1.4}/.gitignore +0 -0
  13. {binsparse-0.1.3 → binsparse-0.1.4}/bin/binsparse_to_binsparse +0 -0
  14. {binsparse-0.1.3 → binsparse-0.1.4}/bin/binsparse_to_npy +0 -0
  15. {binsparse-0.1.3 → binsparse-0.1.4}/bin/npy_to_binsparse +0 -0
  16. {binsparse-0.1.3 → binsparse-0.1.4}/compliance-skips/numpy.txt +0 -0
  17. {binsparse-0.1.3 → binsparse-0.1.4}/compliance-skips/reference.txt +0 -0
  18. {binsparse-0.1.3 → binsparse-0.1.4}/compliance-skips/scipy.txt +0 -0
  19. {binsparse-0.1.3 → binsparse-0.1.4}/compliance-skips/sparse.txt +0 -0
  20. {binsparse-0.1.3 → binsparse-0.1.4}/pixi.lock +0 -0
  21. {binsparse-0.1.3 → binsparse-0.1.4}/src/binsparse/__init__.py +0 -0
  22. {binsparse-0.1.3 → binsparse-0.1.4}/src/binsparse/container.py +0 -0
  23. {binsparse-0.1.3 → binsparse-0.1.4}/src/binsparse/conversions/__init__.py +0 -0
  24. {binsparse-0.1.3 → binsparse-0.1.4}/src/binsparse/conversions/numpy.py +0 -0
  25. {binsparse-0.1.3 → binsparse-0.1.4}/src/binsparse/errors.py +0 -0
  26. {binsparse-0.1.3 → binsparse-0.1.4}/src/binsparse/io.py +0 -0
  27. {binsparse-0.1.3 → binsparse-0.1.4}/src/binsparse/tensor.py +0 -0
  28. {binsparse-0.1.3 → binsparse-0.1.4}/src/binsparse/version.py +0 -0
  29. {binsparse-0.1.3 → binsparse-0.1.4}/src/reference_cli/__init__.py +0 -0
  30. {binsparse-0.1.3 → binsparse-0.1.4}/src/reference_cli/converters.py +0 -0
  31. {binsparse-0.1.3 → binsparse-0.1.4}/src/reference_cli/framework.py +0 -0
  32. {binsparse-0.1.3 → binsparse-0.1.4}/src/reference_cli/reformat.py +0 -0
  33. {binsparse-0.1.3 → binsparse-0.1.4}/src/tests/test_conversion_numpy.py +0 -0
  34. {binsparse-0.1.3 → binsparse-0.1.4}/src/tests/test_conversions.py +0 -0
  35. {binsparse-0.1.3 → binsparse-0.1.4}/src/tests/test_executables.py +0 -0
  36. {binsparse-0.1.3 → binsparse-0.1.4}/src/tests/test_io.py +0 -0
  37. {binsparse-0.1.3 → binsparse-0.1.4}/src/tests/test_reformat.py +0 -0
  38. {binsparse-0.1.3 → binsparse-0.1.4}/src/tests/test_tensor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: binsparse
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Python reference implementation of the Binsparse binary sparse format
5
5
  Author-email: Willow Ahrens <ahrens@gatech.edu>
6
6
  Requires-Python: >=3.11
@@ -75,6 +75,12 @@ require a zero-copy operation when the source and destination support it.
75
75
  NumPy support is included by default; the other adapters require their
76
76
  corresponding optional dependency.
77
77
 
78
+ SciPy, PyData/Sparse, and PyTorch COO imports sort indices and sum duplicate
79
+ entries before constructing a Binsparse tensor, without modifying the source.
80
+ `copy=False` requires canonical (sorted, duplicate-free) input; PyTorch COO
81
+ tensors must be coalesced first. PyTorch CSR/CSC imports rely on PyTorch's
82
+ sorted, unique index invariants.
83
+
78
84
  ## Source
79
85
  The source code for `binsparse` is available on GitHub at [https://github.com/Binsparse/binsparse-reference-python](https://github.com/Binsparse/binsparse-reference-python)
80
86
 
@@ -54,6 +54,12 @@ require a zero-copy operation when the source and destination support it.
54
54
  NumPy support is included by default; the other adapters require their
55
55
  corresponding optional dependency.
56
56
 
57
+ SciPy, PyData/Sparse, and PyTorch COO imports sort indices and sum duplicate
58
+ entries before constructing a Binsparse tensor, without modifying the source.
59
+ `copy=False` requires canonical (sorted, duplicate-free) input; PyTorch COO
60
+ tensors must be coalesced first. PyTorch CSR/CSC imports rely on PyTorch's
61
+ sorted, unique index invariants.
62
+
57
63
  ## Source
58
64
  The source code for `binsparse` is available on GitHub at [https://github.com/Binsparse/binsparse-reference-python](https://github.com/Binsparse/binsparse-reference-python)
59
65
 
@@ -4,7 +4,7 @@ name = "binsparse"
4
4
  description = "Python reference implementation of the Binsparse binary sparse format"
5
5
  readme = "README.md"
6
6
  requires-python = ">= 3.11"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  dependencies = [
9
9
  "h5py>=3.16.0,<4",
10
10
  "numpy>=2,<3",
@@ -26,7 +26,10 @@ def _prepare(value: Any, copy: bool | None) -> Any:
26
26
 
27
27
 
28
28
  def from_scipy(value: Any, *, copy: bool | None = None) -> BinsparseTensor:
29
- """Convert a two-dimensional SciPy CSR, CSC, or COO object to Binsparse."""
29
+ """Convert SciPy CSR, CSC, or COO to Binsparse, sorting and summing duplicates.
30
+
31
+ The input is not modified. ``copy=False`` requires canonical input.
32
+ """
30
33
  scipy_sparse = _scipy_sparse()
31
34
  if not scipy_sparse.issparse(value) or value.ndim != 2:
32
35
  raise TypeError("expected a two-dimensional SciPy sparse array or matrix")
@@ -25,12 +25,28 @@ def _sparse() -> Any:
25
25
 
26
26
 
27
27
  def from_sparse(value: Any, *, copy: bool | None = None) -> BinsparseTensor:
28
- """Convert an N-dimensional PyData/Sparse COO array to Binsparse."""
28
+ """Convert PyData/Sparse COO to Binsparse, sorting and summing duplicates.
29
+
30
+ The input is not modified. ``copy=False`` requires canonical input.
31
+ """
29
32
  sparse = _sparse()
30
33
  if not isinstance(value, sparse.COO) or value.ndim < 1:
31
34
  raise TypeError("expected a non-scalar PyData/Sparse COO array")
32
35
  if copy is True:
33
36
  value = value.copy(deep=True)
37
+ if copy is False:
38
+ linear = value.linear_loc()
39
+ if not np.all(linear[1:] > linear[:-1]):
40
+ raise ValueError("copy=False cannot canonicalize a PyData/Sparse array")
41
+ else:
42
+ value = sparse.COO(
43
+ value.coords,
44
+ value.data,
45
+ shape=value.shape,
46
+ fill_value=value.fill_value,
47
+ sorted=False,
48
+ has_duplicates=True,
49
+ )
34
50
  fill_value = np.asarray(value.fill_value).item()
35
51
  shape = tuple(value.shape)
36
52
  count = int(value.data.size)
@@ -51,7 +51,12 @@ def _numpy(value: Any, copy: bool | None) -> np.ndarray:
51
51
 
52
52
 
53
53
  def from_torch(value: Any, *, copy: bool | None = None) -> BinsparseTensor:
54
- """Convert a PyTorch dense, COO, CSR, or CSC tensor to Binsparse."""
54
+ """Convert PyTorch dense, COO, CSR, or CSC to Binsparse.
55
+
56
+ COO entries are sorted and duplicates summed without modifying the input.
57
+ ``copy=False`` requires coalesced COO input. CSR/CSC inputs are assumed to
58
+ satisfy PyTorch's sorted, unique index invariants.
59
+ """
55
60
  torch = _torch()
56
61
  if not isinstance(value, torch.Tensor):
57
62
  raise TypeError("expected a PyTorch tensor")
@@ -61,10 +66,16 @@ def from_torch(value: Any, *, copy: bool | None = None) -> BinsparseTensor:
61
66
  return from_numpy(array, copy=False if copy is True else copy)
62
67
 
63
68
  if value.layout == torch.sparse_coo:
64
- indices = _numpy(value._indices(), copy)
65
- values = _numpy(value._values(), copy)
66
69
  if value.dense_dim() != 0:
67
70
  raise TypeError("hybrid sparse COO tensors are not supported")
71
+ if not value.is_coalesced():
72
+ if copy is False:
73
+ raise ValueError(
74
+ "copy=False cannot canonicalize a PyTorch sparse tensor"
75
+ )
76
+ value = value.coalesce()
77
+ indices = _numpy(value._indices(), copy)
78
+ values = _numpy(value._values(), copy)
68
79
  if value.sparse_dim() == 2:
69
80
  return COORMatrix(
70
81
  tuple(value.shape),
@@ -79,10 +90,7 @@ def from_torch(value: Any, *, copy: bool | None = None) -> BinsparseTensor:
79
90
  level=SparseLevel(
80
91
  value.sparse_dim(),
81
92
  ElementLevel(values),
82
- tuple(
83
- indices[dimension, :]
84
- for dimension in range(value.sparse_dim())
85
- ),
93
+ tuple(indices[dimension, :] for dimension in range(value.sparse_dim())),
86
94
  ),
87
95
  )
88
96
 
@@ -28,22 +28,36 @@ def test_scipy_round_trip(constructor, tensor_type) -> None:
28
28
  assert result.dtype == source.dtype
29
29
 
30
30
 
31
- def test_scipy_csr_is_sorted_and_unique() -> None:
32
- source = scipy_sparse.csr_array(
31
+ @pytest.mark.parametrize(
32
+ "constructor", [scipy_sparse.csr_array, scipy_sparse.csc_array]
33
+ )
34
+ def test_scipy_compressed_is_sorted_and_unique(constructor) -> None:
35
+ source = constructor(
33
36
  (
34
37
  np.array([1, 2, 3, 4, 5, 6]),
35
38
  np.array([2, 1, 2, 1, 0, 1]),
36
39
  np.array([0, 3, 6]),
37
40
  ),
38
- shape=(2, 3),
41
+ shape=(2, 3) if constructor is scipy_sparse.csr_array else (3, 2),
39
42
  )
43
+ original = (source.indptr.copy(), source.indices.copy(), source.data.copy())
40
44
  tensor = from_scipy(source)
41
45
  result = to_scipy(tensor)
42
46
 
47
+ assert isinstance(tensor, (CSRMatrix, CSCMatrix))
43
48
  assert tensor.number_of_stored_values == 4
49
+ np.testing.assert_array_equal(tensor.pointers_to_1, [0, 2, 4])
50
+ np.testing.assert_array_equal(tensor.indices_1, [1, 2, 0, 1])
51
+ np.testing.assert_array_equal(tensor.values, [2, 4, 5, 10])
44
52
  assert result.has_canonical_format
45
53
  np.testing.assert_array_equal(result.toarray(), source.toarray())
46
54
  assert not source.has_canonical_format
55
+ for actual, expected in zip(
56
+ (source.indptr, source.indices, source.data), original, strict=True
57
+ ):
58
+ np.testing.assert_array_equal(actual, expected)
59
+ with pytest.raises(ValueError, match="canonicalize"):
60
+ from_scipy(source, copy=False)
47
61
 
48
62
 
49
63
  def test_scipy_coo_is_sorted_and_unique() -> None:
@@ -57,7 +71,11 @@ def test_scipy_coo_is_sorted_and_unique() -> None:
57
71
  tensor = from_scipy(source)
58
72
  result = to_scipy(tensor)
59
73
 
74
+ assert isinstance(tensor, COORMatrix)
60
75
  assert tensor.number_of_stored_values == 2
76
+ np.testing.assert_array_equal(tensor.indices_0, [0, 1])
77
+ np.testing.assert_array_equal(tensor.indices_1, [2, 0])
78
+ np.testing.assert_array_equal(tensor.values, [6, 4])
61
79
  assert result.has_canonical_format
62
80
  np.testing.assert_array_equal(result.toarray(), source.toarray())
63
81
  assert not source.has_canonical_format
@@ -88,7 +106,6 @@ def test_scipy_copy_false_rejects_required_input_canonicalization() -> None:
88
106
  from_scipy(source, copy=False)
89
107
 
90
108
 
91
-
92
109
  def test_scipy_canonical_coo_copy_false_shares_buffers() -> None:
93
110
  source = scipy_sparse.coo_array(
94
111
  (np.array([3]), (np.array([0]), np.array([1]))),
@@ -9,6 +9,39 @@ from binsparse.tensor import COORMatrix, CustomTensor, ElementLevel, SparseLevel
9
9
  sparse = pytest.importorskip("sparse")
10
10
 
11
11
 
12
+ @pytest.mark.parametrize("ndim", [1, 2, 3])
13
+ @pytest.mark.parametrize("copy", [None, True, False])
14
+ @pytest.mark.parametrize("duplicates", [False, True])
15
+ def test_pydata_sparse_is_sorted_and_unique(ndim, copy, duplicates) -> None:
16
+ coordinates = [2, 0, 2, 1] if duplicates else [2, 0, 1]
17
+ coords = np.array([coordinates] * ndim)
18
+ data = np.arange(1, len(coordinates) + 1, dtype=np.float32)
19
+ # Bypass the constructor's normalization to exercise noncanonical buffers.
20
+ source = sparse.COO(
21
+ coords.copy(),
22
+ data.copy(),
23
+ shape=(3,) * ndim,
24
+ sorted=True,
25
+ has_duplicates=False,
26
+ fill_value=7,
27
+ )
28
+ if copy is False:
29
+ with pytest.raises(ValueError, match="canonicalize"):
30
+ from_sparse(source, copy=copy)
31
+ else:
32
+ tensor = from_sparse(source, copy=copy)
33
+ result = to_sparse(tensor)
34
+ assert tensor.number_of_stored_values == 3
35
+ assert tensor.fill_value == 7
36
+ assert result.dtype == data.dtype
37
+ np.testing.assert_array_equal(result.coords, [[0, 1, 2]] * ndim)
38
+ np.testing.assert_array_equal(
39
+ result.data, [2, 4, 4] if duplicates else [2, 3, 1]
40
+ )
41
+ np.testing.assert_array_equal(source.coords, coords)
42
+ np.testing.assert_array_equal(source.data, data)
43
+
44
+
12
45
  def test_pydata_sparse_round_trip() -> None:
13
46
  source = sparse.COO.from_numpy(np.array([[0, 2, 0], [3, 0, 4]], dtype=np.float32))
14
47
  tensor = from_sparse(source)
@@ -6,6 +6,7 @@ import pytest
6
6
  from binsparse.conversions import from_torch, to_torch
7
7
  from binsparse.tensor import (
8
8
  COORMatrix,
9
+ CSCMatrix,
9
10
  CSRMatrix,
10
11
  CustomTensor,
11
12
  DenseLevel,
@@ -48,22 +49,63 @@ def test_torch_csr_round_trip() -> None:
48
49
  assert torch.equal(result.to_dense(), source.to_dense())
49
50
 
50
51
 
51
- def test_torch_uncoalesced_coo_preserves_entries() -> None:
52
+ @pytest.mark.parametrize("ndim", [1, 2, 3])
53
+ @pytest.mark.parametrize("copy", [None, True, False])
54
+ def test_torch_uncoalesced_coo_is_sorted_and_unique(ndim, copy) -> None:
55
+ coords = torch.tensor([[2, 0, 2, 1]] * ndim)
56
+ values = torch.tensor([1.0, 2.0, -1.0, 4.0])
52
57
  source = torch.sparse_coo_tensor(
53
- torch.tensor([[0, 0], [1, 1]]),
54
- torch.tensor([2.0, 3.0]),
55
- (2, 2),
58
+ coords.clone(),
59
+ values.clone(),
60
+ (3,) * ndim,
56
61
  )
57
- tensor = from_torch(source)
58
-
59
- assert isinstance(tensor, COORMatrix)
60
- assert tensor.number_of_stored_values == 2
62
+ if copy is False:
63
+ with pytest.raises(ValueError, match="canonicalize"):
64
+ from_torch(source, copy=copy)
65
+ else:
66
+ tensor = from_torch(source, copy=copy)
67
+ result = to_torch(tensor)
68
+ assert tensor.number_of_stored_values == 3
69
+ assert result.dtype == source.dtype
70
+ assert torch.equal(result._indices(), torch.tensor([[0, 1, 2]] * ndim))
71
+ assert torch.equal(result._values(), torch.tensor([2.0, 4.0, 0.0]))
72
+ assert torch.equal(result.to_dense(), source.to_dense())
73
+ assert not source.is_coalesced()
74
+ assert torch.equal(source._indices(), coords)
75
+ assert torch.equal(source._values(), values)
76
+
77
+
78
+ @pytest.mark.parametrize("layout", ["coo", "csr", "csc"])
79
+ @pytest.mark.parametrize("empty", [False, True])
80
+ def test_torch_canonical_sparse_copy_policy(layout, empty) -> None:
81
+ dense = torch.zeros((3, 3)) if empty else torch.eye(3)
82
+ source = dense.to_sparse(layout=getattr(torch, f"sparse_{layout}"))
83
+ shared = from_torch(source, copy=False)
84
+ copied = from_torch(source, copy=True)
85
+ assert isinstance(shared, (COORMatrix, CSRMatrix, CSCMatrix))
86
+ assert isinstance(copied, (COORMatrix, CSRMatrix, CSCMatrix))
87
+ original_values = source.values().numpy()
88
+ np.testing.assert_array_equal(copied.values, original_values)
89
+ if not empty:
90
+ assert np.shares_memory(shared.values, original_values)
91
+ assert not np.shares_memory(copied.values, original_values)
92
+ if layout == "coo":
93
+ assert isinstance(shared, COORMatrix)
94
+ assert isinstance(copied, COORMatrix)
95
+ assert np.shares_memory(shared.indices_0, source.indices().numpy())
96
+ assert not np.shares_memory(copied.indices_0, source.indices().numpy())
97
+ else:
98
+ indices = source.col_indices() if layout == "csr" else source.row_indices()
99
+ assert np.shares_memory(shared.indices_1, indices.numpy())
100
+ assert not np.shares_memory(copied.indices_1, indices.numpy())
61
101
 
62
102
 
63
103
  @pytest.mark.parametrize("shape", [(5,), (2, 3, 4)])
64
104
  def test_torch_nd_coo_round_trip(shape) -> None:
65
105
  coordinates = torch.tensor([[0, size - 1] for size in shape])
66
- source = torch.sparse_coo_tensor(coordinates, torch.tensor([2.0, 3.0]), shape)
106
+ source = torch.sparse_coo_tensor(
107
+ coordinates, torch.tensor([2.0, 3.0]), shape
108
+ ).coalesce()
67
109
  tensor = from_torch(source, copy=False)
68
110
  result = to_torch(tensor)
69
111
 
@@ -72,8 +114,7 @@ def test_torch_nd_coo_round_trip(shape) -> None:
72
114
  assert tensor.level.rank == len(shape)
73
115
  assert isinstance(tensor.level.level, ElementLevel)
74
116
  assert all(
75
- index.__array_interface__["data"][0]
76
- == source._indices()[dimension].data_ptr()
117
+ index.__array_interface__["data"][0] == source._indices()[dimension].data_ptr()
77
118
  for dimension, index in enumerate(tensor.level.indices)
78
119
  )
79
120
  assert torch.equal(result.to_dense(), source.to_dense())
File without changes
File without changes
File without changes