Skip to content

lythonic.vector

A universe plus one value per key.

A universe plus one value per key.

KeyedVector is the one-dimensional companion to the keyed matrix types: keyed like a dict, aligned like an array, and serializable with no optional dependency installed. Every key in the universe has an entry - there is no absent entry, no fill value, and no sparse form.

>>> v = KeyedVector.from_mapping({"USD": 0.4, "EUR": 0.6})
>>> v.value("EUR")
0.6
>>> v.to_dict()
{'USD': 0.4, 'EUR': 0.6}

cast projects onto another universe, which is how a vector is aligned to a matrix axis or to another vector. Keys outside the new universe are dropped; keys it introduces are filled with NaN unless another fill is given.

>>> v.cast(["EUR", "JPY"], fill=0.0).to_dict()
{'EUR': 0.6, 'JPY': 0.0}

Unlike ExposureMatrix and SymmetricMatrix, this type stores NaN and the infinities. It is a far more general object - whatever a caller has one value per key of - and computed results legitimately contain non-finite values. Rejecting them would not prevent them, only move the failure to a later boundary. See docs/adr/0005-keyed-vector-admits-non-finite-values.md, which also covers the two consequences: non-finite values serialize as "NaN", "Infinity" and "-Infinity" strings, and equality is defined here rather than inherited.

The inherited equality is not merely strict but incoherent - a list of floats compares elementwise by identity first, so a NaN-carrying vector equals itself but not an identical copy, and a vector loaded from JSON compares unequal to the one that was saved. __eq__ therefore compares universes and then values positionally with NaN matching NaN, the rule numpy.array_equal(..., equal_nan=True) uses. SymmetricMatrix needs none of this, since it admits no NaN and draws its guarantee from canonical storage.

There is no keyed access by subscript and no iteration over keys or values. Universe already implements the container protocol and is a public field, so re-exposing it would be trivial delegation - and v[0] against v.universe[0] would mean different things. Omitting them also sidesteps the keys-versus-values coin flip that container protocols on keyed types force on readers. BaseModel does supply an __iter__ over fields, inherited by every model here; it yields neither keys nor values, so it raises no ambiguity, but it cannot be removed.

Conversions live behind one facade per library, as in lythonic.frame:

v = KeyedVector.np.from_array(arr, universe)   # numpy array in
arr = v.np.array()                             # numpy array out

v = KeyedVector.pd.from_series(s)              # pandas Series in
s = v.pd.series()                              # pandas Series out

Polars and pyarrow are deliberately absent: neither has an index, so any mapping would be a lossy drop or an invented two-column convention. Callers who want either can route through FrameData.

KeyedVector

Bases: BaseModel

One value per key of a universe, in universe order.

Immutable. Reads by key raise KeyError for a key outside the universe; there is no "absent entry" state, so a key that is present always has a value.

Source code in src/lythonic/vector.py
class KeyedVector(BaseModel):
    """
    One value per key of a universe, in universe order.

    Immutable. Reads by key raise `KeyError` for a key outside the universe;
    there is no "absent entry" state, so a key that is present always has a
    value.
    """

    model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True, ser_json_inf_nan="strings")

    universe: Universe
    values: list[float]

    np: ClassVar[LibAccess[KeyedVector, VectorNpIn, VectorNpOut]] = LibAccess(
        VectorNpIn, VectorNpOut
    )
    """Numpy facade. Class access gives constructors, instance access conversions."""

    pd: ClassVar[LibAccess[KeyedVector, VectorPdIn, VectorPdOut]] = LibAccess(
        VectorPdIn, VectorPdOut
    )
    """Pandas facade. Class access gives constructors, instance access conversions."""

    @model_validator(mode="after")
    def _check_length(self) -> KeyedVector:
        if len(self.values) != len(self.universe):
            raise ValueError(f"{len(self.values)} values for {len(self.universe)} keys")
        return self

    @classmethod
    def from_mapping(cls, mapping: Mapping[str, float]) -> KeyedVector:
        """
        Build from a mapping, taking the universe from its insertion order.

        This is the inverse of `to_dict`, and the bridge from the matrix
        types' dict accessors, which return an entry per key in universe
        order.
        """
        return cls(universe=Universe(mapping.keys()), values=list(mapping.values()))

    def value(self, key: str) -> float:
        """Value at one key."""
        return self.values[self.universe.index(key)]

    def to_dict(self) -> dict[str, float]:
        """The whole vector as a mapping, in universe order."""
        return dict(zip(self.universe, self.values, strict=True))

    def cast(self, universe: Universe | list[str], fill: float = nan) -> KeyedVector:
        """
        A new vector over `universe`, dropping keys outside it.

        Keys the cast introduces take `fill`. The NaN default is what makes
        silent extension acceptable: a fabricated NaN cannot pass for a
        measurement, because any arithmetic touching it yields NaN. Returns
        `self` when the universe is already equal, so a defensive alignment
        call is free.
        """
        target = Universe(universe)
        if target == self.universe:
            return self
        values = [
            self.values[self.universe.index(key)] if key in self.universe else fill
            for key in target
        ]
        return KeyedVector(universe=target, values=values)

    @override
    def __eq__(self, other: Any) -> bool:
        if not isinstance(other, KeyedVector):
            return False
        if self.universe != other.universe:
            return False
        return all(_same_value(a, b) for a, b in zip(self.values, other.values, strict=True))

np = LibAccess(VectorNpIn, VectorNpOut) class-attribute

Numpy facade. Class access gives constructors, instance access conversions.

pd = LibAccess(VectorPdIn, VectorPdOut) class-attribute

Pandas facade. Class access gives constructors, instance access conversions.

from_mapping(mapping) classmethod

Build from a mapping, taking the universe from its insertion order.

This is the inverse of to_dict, and the bridge from the matrix types' dict accessors, which return an entry per key in universe order.

Source code in src/lythonic/vector.py
@classmethod
def from_mapping(cls, mapping: Mapping[str, float]) -> KeyedVector:
    """
    Build from a mapping, taking the universe from its insertion order.

    This is the inverse of `to_dict`, and the bridge from the matrix
    types' dict accessors, which return an entry per key in universe
    order.
    """
    return cls(universe=Universe(mapping.keys()), values=list(mapping.values()))

value(key)

Value at one key.

Source code in src/lythonic/vector.py
def value(self, key: str) -> float:
    """Value at one key."""
    return self.values[self.universe.index(key)]

to_dict()

The whole vector as a mapping, in universe order.

Source code in src/lythonic/vector.py
def to_dict(self) -> dict[str, float]:
    """The whole vector as a mapping, in universe order."""
    return dict(zip(self.universe, self.values, strict=True))

cast(universe, fill=nan)

A new vector over universe, dropping keys outside it.

Keys the cast introduces take fill. The NaN default is what makes silent extension acceptable: a fabricated NaN cannot pass for a measurement, because any arithmetic touching it yields NaN. Returns self when the universe is already equal, so a defensive alignment call is free.

Source code in src/lythonic/vector.py
def cast(self, universe: Universe | list[str], fill: float = nan) -> KeyedVector:
    """
    A new vector over `universe`, dropping keys outside it.

    Keys the cast introduces take `fill`. The NaN default is what makes
    silent extension acceptable: a fabricated NaN cannot pass for a
    measurement, because any arithmetic touching it yields NaN. Returns
    `self` when the universe is already equal, so a defensive alignment
    call is free.
    """
    target = Universe(universe)
    if target == self.universe:
        return self
    values = [
        self.values[self.universe.index(key)] if key in self.universe else fill
        for key in target
    ]
    return KeyedVector(universe=target, values=values)

VectorNpIn

Class-access numpy facade: constructors that take a dense array.

Source code in src/lythonic/vector.py
class VectorNpIn:
    """Class-access numpy facade: constructors that take a dense array."""

    _owner: type[KeyedVector]
    _numpy: ModuleType

    def __init__(self, owner: type[KeyedVector]) -> None:
        self._owner = owner
        self._numpy = require("numpy")

    def from_array(self, arr: NDArray[np.float64], universe: Universe | list[str]) -> KeyedVector:
        """Pair a one-dimensional array with a universe of the same length."""
        universe = Universe(universe)
        if arr.shape != (len(universe),):
            raise ValueError(f"array shape {arr.shape} does not match {len(universe)} keys")
        return self._owner(universe=universe, values=[float(v) for v in arr.tolist()])

from_array(arr, universe)

Pair a one-dimensional array with a universe of the same length.

Source code in src/lythonic/vector.py
def from_array(self, arr: NDArray[np.float64], universe: Universe | list[str]) -> KeyedVector:
    """Pair a one-dimensional array with a universe of the same length."""
    universe = Universe(universe)
    if arr.shape != (len(universe),):
        raise ValueError(f"array shape {arr.shape} does not match {len(universe)} keys")
    return self._owner(universe=universe, values=[float(v) for v in arr.tolist()])

VectorNpOut

Instance-access numpy facade: dense views of a vector.

Source code in src/lythonic/vector.py
class VectorNpOut:
    """Instance-access numpy facade: dense views of a vector."""

    _v: KeyedVector
    _numpy: ModuleType

    def __init__(self, owner: KeyedVector) -> None:
        self._v = owner
        self._numpy = require("numpy")

    def array(self) -> NDArray[np.float64]:
        """The values as a `float64` array, in universe order."""
        return self._numpy.array(self._v.values, dtype=self._numpy.float64)

array()

The values as a float64 array, in universe order.

Source code in src/lythonic/vector.py
def array(self) -> NDArray[np.float64]:
    """The values as a `float64` array, in universe order."""
    return self._numpy.array(self._v.values, dtype=self._numpy.float64)

VectorPdIn

Class-access pandas facade: constructors that take a pandas Series.

Source code in src/lythonic/vector.py
class VectorPdIn:
    """Class-access pandas facade: constructors that take a pandas Series."""

    _owner: type[KeyedVector]
    _pandas: ModuleType

    def __init__(self, owner: type[KeyedVector]) -> None:
        self._owner = owner
        self._pandas = require("pandas")

    def from_series(self, series: pd.Series) -> KeyedVector:  # pyright: ignore[reportMissingTypeArgument, reportUnknownParameterType]
        """
        Take a Series' index as the universe, preserving its order.

        The index carries no uniqueness or type guarantee of its own, so both
        are checked here rather than left to produce a silently short universe
        or a non-string key downstream.
        """
        keys: list[Any] = list(series.index)  # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType]
        if len(set(keys)) != len(keys):
            raise ValueError("series index has duplicate entries")
        string_keys = [k for k in keys if isinstance(k, str)]
        if len(string_keys) != len(keys):
            raise TypeError("series index must be all string keys")
        values = [float(v) for v in series.tolist()]  # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType, reportUnknownArgumentType]
        return self._owner(universe=Universe(string_keys), values=values)  # pyright: ignore[reportUnknownArgumentType]

from_series(series)

Take a Series' index as the universe, preserving its order.

The index carries no uniqueness or type guarantee of its own, so both are checked here rather than left to produce a silently short universe or a non-string key downstream.

Source code in src/lythonic/vector.py
def from_series(self, series: pd.Series) -> KeyedVector:  # pyright: ignore[reportMissingTypeArgument, reportUnknownParameterType]
    """
    Take a Series' index as the universe, preserving its order.

    The index carries no uniqueness or type guarantee of its own, so both
    are checked here rather than left to produce a silently short universe
    or a non-string key downstream.
    """
    keys: list[Any] = list(series.index)  # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType]
    if len(set(keys)) != len(keys):
        raise ValueError("series index has duplicate entries")
    string_keys = [k for k in keys if isinstance(k, str)]
    if len(string_keys) != len(keys):
        raise TypeError("series index must be all string keys")
    values = [float(v) for v in series.tolist()]  # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType, reportUnknownArgumentType]
    return self._owner(universe=Universe(string_keys), values=values)  # pyright: ignore[reportUnknownArgumentType]

VectorPdOut

Instance-access pandas facade: Series views of a vector.

Source code in src/lythonic/vector.py
class VectorPdOut:
    """Instance-access pandas facade: Series views of a vector."""

    _v: KeyedVector
    _pandas: ModuleType

    def __init__(self, owner: KeyedVector) -> None:
        self._v = owner
        self._pandas = require("pandas")

    def series(self) -> pd.Series:  # pyright: ignore[reportMissingTypeArgument, reportUnknownParameterType]
        """The vector as a Series indexed by the universe."""
        v = self._v
        return self._pandas.Series(list(v.values), index=list(v.universe), dtype="float64")  # pyright: ignore[reportUnknownMemberType]

series()

The vector as a Series indexed by the universe.

Source code in src/lythonic/vector.py
def series(self) -> pd.Series:  # pyright: ignore[reportMissingTypeArgument, reportUnknownParameterType]
    """The vector as a Series indexed by the universe."""
    v = self._v
    return self._pandas.Series(list(v.values), index=list(v.universe), dtype="float64")  # pyright: ignore[reportUnknownMemberType]