Sparse vector in Python
Vector stored as a symbol table of its nonzero entries, making dot products cost proportional to the nonzero count.
- File
sparse_vector.py, 106 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/sparse-vector/. The authors are listed in prov/E8QPZ3.jsonl.
import math
from collections.abc import Iterator, Sequence
class SparseVector:
"""A vector of dimension d stored as a hash table of its nonzero entries.
get and put take O(1) expected time, dot with another sparse vector takes
O(1 + min(nnz(u), nnz(w))) expected time, dot with a dense list takes
O(1 + nnz), plus takes O(1 + nnz(u) + nnz(w)) expected time. Space is
Theta(1 + nnz). No stored value is ever 0.
"""
def __init__(self, d: int, entries: dict[int, float] | None = None) -> None:
if d < 0:
raise ValueError("dimension must be nonnegative")
self.d = d
self.table: dict[int, float] = {}
if entries:
for i, x in entries.items():
self.put(i, x)
def _check_index(self, i: int) -> None:
if not 0 <= i < self.d:
raise IndexError(f"index {i} out of range for dimension {self.d}")
def put(self, i: int, x: float) -> None:
"""Set entry i to x; a zero value removes the index from the table."""
self._check_index(i)
if x == 0: # @line L1
self.table.pop(i, None)
else:
self.table[i] = x # @line L2
def get(self, i: int) -> float:
"""Return entry i, which is 0 when i is not stored."""
self._check_index(i)
return self.table.get(i, 0.0) # @line L3
def nnz(self) -> int:
"""Return the number of nonzero entries."""
return len(self.table)
def items(self) -> Iterator[tuple[int, float]]:
"""Yield the pairs (index, value) of the nonzero entries in index order."""
for i in sorted(self.table):
yield i, self.table[i]
def dot(self, other: "SparseVector") -> float:
"""Return the dot product with another sparse vector of the same dimension."""
if self.d != other.d:
raise ValueError("dimensions differ")
u, w = self, other
if len(u.table) > len(w.table): # @line L4
u, w = w, u
s = 0.0 # @line L5
for i, x in u.table.items(): # @line L6
s += x * w.table.get(i, 0.0) # @line L7
return s # @line L8
def dot_dense(self, a: Sequence[float]) -> float:
"""Return the dot product with a dense sequence of length d."""
if len(a) != self.d:
raise ValueError("dimensions differ")
s = 0.0
for i, x in self.table.items():
s += x * a[i]
return s
def plus(self, other: "SparseVector") -> "SparseVector":
"""Return the sum as a new sparse vector; entries that cancel are dropped."""
if self.d != other.d:
raise ValueError("dimensions differ")
r = SparseVector(self.d)
r.table = dict(self.table)
for i, x in other.table.items():
r.put(i, r.get(i) + x)
return r
def scale(self, alpha: float) -> "SparseVector":
"""Return alpha times this vector as a new sparse vector."""
r = SparseVector(self.d)
if alpha != 0:
for i, x in self.table.items():
r.put(i, alpha * x)
return r
def norm(self) -> float:
"""Return the Euclidean norm, the square root of the dot product with itself."""
return math.sqrt(self.dot(self))
def to_dense(self) -> list[float]:
"""Return the vector as a dense list of length d."""
a = [0.0] * self.d
for i, x in self.table.items():
a[i] = x
return a
def check_invariant(self) -> None:
"""Check that every key is in range and every stored value is nonzero."""
for i, x in self.table.items():
assert isinstance(i, int) and 0 <= i < self.d, i
assert x != 0, (i, x)
test_sparse_vector.py 76 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/sparse-vector/. The authors are listed in prov/E8QPZ3.jsonl.
import math
import random
import pytest
from sparse_vector import SparseVector
def test_example() -> None:
u = SparseVector(10, {1: 0.5, 4: 2.0, 7: -1.0})
w = SparseVector(10, {0: 3.0, 4: 1.5, 7: 2.0, 9: 1.0})
assert u.get(5) == 0
assert u.dot(w) == 1.0 and w.dot(u) == 1.0
assert dict(u.plus(w).items()) == {0: 3.0, 1: 0.5, 4: 3.5, 7: 1.0, 9: 1.0}
z = SparseVector(10, {7: 1.0})
s = u.plus(z)
assert dict(s.items()) == {1: 0.5, 4: 2.0}
s.check_invariant()
assert math.isclose(u.norm(), 2.29128784747792)
u.put(4, 0)
assert dict(u.items()) == {1: 0.5, 7: -1.0}
u.check_invariant()
def test_edges() -> None:
e = SparseVector(0)
assert e.nnz() == 0 and e.norm() == 0.0 and e.dot(SparseVector(0)) == 0.0
v = SparseVector(3)
with pytest.raises(IndexError):
v.put(3, 1.0)
with pytest.raises(IndexError):
v.get(-1)
with pytest.raises(ValueError):
v.dot(SparseVector(4))
with pytest.raises(ValueError):
v.plus(SparseVector(4))
with pytest.raises(ValueError):
v.dot_dense([1.0, 2.0])
v.put(0, 0)
assert v.nnz() == 0
v.put(2, 5.0)
assert v.scale(0).nnz() == 0
assert v.scale(-2).to_dense() == [0.0, 0.0, -10.0]
assert v.dot_dense([1.0, 1.0, 3.0]) == 15.0
def test_random_against_dense() -> None:
rng = random.Random(20261104)
for _ in range(600):
d = rng.randint(1, 40)
u, w = SparseVector(d), SparseVector(d)
du, dw = [0] * d, [0] * d
for _ in range(rng.randint(0, 60)):
vec, dense = (u, du) if rng.random() < 0.5 else (w, dw)
i = rng.randrange(d)
x = rng.choice([0, 0, rng.randint(-5, 5)])
vec.put(i, x)
dense[i] = x
vec.check_invariant()
assert u.to_dense() == du and w.to_dense() == dw
assert u.nnz() == sum(1 for x in du if x != 0)
assert u.dot(w) == sum(a * b for a, b in zip(du, dw))
assert u.dot_dense(dw) == u.dot(w)
r = u.plus(w)
r.check_invariant()
assert r.to_dense() == [a + b for a, b in zip(du, dw)]
alpha = rng.randint(-3, 3)
sc = u.scale(alpha)
sc.check_invariant()
assert sc.to_dense() == [alpha * a for a in du]
assert math.isclose(u.norm(), math.sqrt(sum(a * a for a in du)))
for i in range(d):
assert u.get(i) == du[i]
What the next level needs
- the tests did not run in this build