Named axes

Value labels name the points on an axis, such as low and high. An axis name names the wire itself, such as A or C. The same labels can sit on several wires; the names still tell them apart. Omit names and the tensor behaves as before.

Pass names as (domain names, codomain names). They are unique in one tensor. names is (None, None) when the tensor is unnamed. rename() replaces names, and axis() returns the index with domain axes counted first, as in get_label_index().

Marginal

marginal() keeps the named codomain wires in the order given and sums out the rest, including a wire in the middle. marginalize() does the same by listing the wires to drop; the others stay in their original order.

For a state p: I → A ⊗ B ⊗ C, keeping B is the deterministic wiring that discards A and C:

p ; (! ⊗ id_B ⊗ !) = p_B.

Discarding named wires A and C leaves B

Read top to bottom. Left: p ; (! ⊗ id_B ⊗ !). The dots are discard. Right: the marginal state on B.

Labels move with the kept wires. The domain is unchanged.

Permute

permute() reorders the codomain into the given names. The domain stays put, and labels move with their wires. For a state this reorders every axis. The reordering is a permutation of the wires, drawn as crossings:

Named wires A, B, C reordered to C, A, B

Read top to bottom. Left: p with wires A, B, C crossed into the order C, A, B. Right: the reordered state.

Observe

observe() conditions a state on outcomes written as name=label. Without labels, the value is an integer index. The method moves the observed wires to the front with permute(), applies conditionalization(), and reads that row. It returns the posterior joint on the remaining wires, in their original order, and the probability of the evidence. Several outcomes at once match observing them one by one; the probability is then the joint probability of all of them. Evidence 0 raises ValueError.

On a chain A → C → D, the A marginal of observe(C="high") is the row of bayesian_inversion() for that value of C.

import numpy as np
from fxtensor_salmon import FXTensor

level = ["low", "high"]
prior_data = np.array([0.25, 0.75])
given_a = np.array([
    [0.8, 0.2],  # A = low
    [0.4, 0.6],  # A = high
])
given_c = np.array([
    [0.9, 0.1],  # C = low
    [0.3, 0.7],  # C = high
])
joint = FXTensor(
    [[], [level, level, level]],
    data=prior_data[:, None, None] * given_a[:, :, None] * given_c[None, :, :],
    names=([], ["A", "C", "D"]),
)
post, evidence = joint.observe(C="high")
assert post.names == (None, ["A", "D"])
assert post.marginal("D").names == (None, ["D"])

prior = FXTensor([[], [level]], data=prior_data, names=([], ["A"]))
kernel = FXTensor([[level], [level]], data=given_a, names=(["A"], ["C"]))
inversion = kernel.bayesian_inversion(prior)
assert np.allclose(post.marginal("A").data, inversion.data[level.index("high")])
assert np.isclose(evidence, joint.marginal("C").data[level.index("high")])

How names travel

composition() checks the codomain names of self against the domain names of other when both are present. The result keeps the domain names of self and the codomain names of other. tensor_product() concatenates names and raises ValueError if a name is repeated. If one factor has axes but no names, the product is unnamed. bayesian_inversion() swaps domain and codomain names. conditional() and conditionalization() split names at the same index as the axes.

to_json() stores names under names as [domain names, codomain names]. JSON with no names key loads as an unnamed tensor.