Getting started#

Tenspec declares the shape and the dtype of a NumPy array or a PyTorch tensor, in the annotation beside the value. A PyTorch declaration can also require a device. Tenspec checks that declaration at one boundary: a function, a Pydantic model, or one standalone call.

A declaration is an ordinary annotation, so a type checker reads it and untouched code still runs. Tenspec accepts or refuses the array the caller passed. It converts nothing, moves nothing, and copies nothing unless the declaration names a transform that copies.

Install#

Install it with pip, or with uv. Either one is enough, so run the line for the tool you use:

pip install "tenspec[numpy]"   # or: uv add "tenspec[numpy]"

Name the array library you need as an extra: numpy or torch. import tenspec loads neither one.

This is an early release, at version 0.1.0, and the interface can change before version 1.0. It needs Python 3.13 or newer. The tested interpreter and array-library combinations are in the support matrix.

The three forms#

Three forms declare the same relation: a matrix of rows by features, and one weight per feature. Each one opens a boundary of its own, and each refuses the same disagreement.

A checked function#

from typing import Literal as Shape

import numpy as np

from tenspec import checked
from tenspec.numpy import Float


@checked
def weigh_columns(
    values: Float[Shape["rows features"]], weights: Float[Shape["features"]]
) -> Float[Shape["rows features"]]:
    return values * weights


print(weigh_columns(np.ones((3, 4)), np.array([1.0, 2.0, 3.0, 4.0])).shape)

weights must cover as many features as values has columns, and the returned matrix must keep the shape it came with. Remove @checked and the declarations stay as ordinary annotations that a type checker still reads, with no Tenspec check at run time.

A model#

from typing import Literal as Shape

import numpy as np

from pydantic import BaseModel, ConfigDict, Field
from tenspec import TensorContracts
from tenspec.numpy import Float


class ProjectBase(BaseModel):
    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)


class WeighedColumns(TensorContracts, ProjectBase):
    values: Float[Shape["rows features"]] = Field(description="one row per observation")
    weights: Float[Shape["features"]] = Field(description="one weight per feature")


block = WeighedColumns(values=np.ones((3, 4)), weights=np.array([1.0, 2.0, 3.0, 4.0]))
print(block.values.shape, block.weights.shape)

The field annotations establish the relation at construction, with no handwritten shape validator. The caller’s own base owns the configuration, and it must permit arbitrary types, because an array is not a Pydantic type.

One standalone call#

from typing import Literal as Shape

import numpy as np

from pydantic import ValidationError
from tenspec import validate
from tenspec.numpy import Float

weighed = tuple[Float[Shape["rows features"]], Float[Shape["features"]]]

print([item.shape for item in validate((np.ones((3, 4)), np.ones(4)), weighed)])

try:
    validate((np.ones((3, 4)), np.ones(5)), weighed)
except ValidationError as refusal:
    print(refusal.errors()[0]["msg"])

One tuple is one boundary, which is what relates the two arrays. Two separate validate calls relate nothing. The refusal names the axis, both extents, and the bindings the boundary held:

Value error, expected features=4, but the array has features=5, with {'rows': 3, 'features': 4}

Write from typing import Literal as Shape. tenspec.Shape is a public re-export of typing.Literal and works at run time, but Ruff 0.16.6 does not follow it. See Ruff and the Shape alias.