Conditional independence

is_conditionally_independent() asks whether a state I → X ⊗ Y ⊗ Z or a kernel A → X ⊗ Y ⊗ Z displays X ⊥ Y | Z. split_x and split_y are 1-based positions along the codomain, the same convention as conditional. Factors before split_x are X, factors from split_x up to but not including split_y are Y, and the rest are Z. Any block may be empty.

The check rebuilds p(x|z) p(y|z) p(z) with marginalization, conditional and copy_tensor, then compares the arrays with numpy.allclose. atol and rtol default to FXTensor._ATOL and FXTensor._RTOL.

is_independent() is the case with Z empty: split is the first factor of Y.

Conditional independence as a copy–discard equation, and the conditional product

Read bottom to top: the bottom of each panel is the domain and the top is the codomain. The joint wires are ordered X ⊗ Z ⊗ Y, as drawn. Left: X ⊥ Y | Z is the copy–discard equation. Right: the conditional_product construction p_Z ; copy_Z ; (p(x|z) ⊗ id_Z ⊗ q(y|z)). Bottom: when Z is trivial, conditional independence is the product of the marginals. The API lays the same variables out as X ⊗ Y ⊗ Z (Z last), so a tensor returned by conditional_product is ordered X ⊗ Y ⊗ Z.

Conditional product

conditional_product() takes joints p_{XZ}: I → X ⊗ Z and q_{YZ}: I → Y ⊗ Z (or kernels that share a domain) and returns the joint on X ⊗ Y ⊗ Z that is conditionally independent given Z and has those marginals. on is the 1-based index of the first Z factor in both codomains, or a pair (on_p, on_q) when X and Y have different numbers of factors. The Z factor sizes must match, and the two Z marginals must agree within atol and rtol; otherwise the method raises ValueError. An out-of-range split raises ValueError as well.

The construction is p_Z ; copy_Z ; (p(x|z) ⊗ id_Z ⊗ q(y|z)), which is the right-hand panel of the figure (wires X ⊗ Z ⊗ Y). The method then moves Z past Y so the returned codomain is X ⊗ Y ⊗ Z.

Forecast, weather, and umbrella

The forecast drives both the weather you meet and whether you carry an umbrella. Given the forecast, weather and umbrella are conditionally independent. The marginal on weather and umbrella still couples them, so is_independent is false there.

import numpy as np
from fxtensor_salmon import FXTensor

forecast = ["Sunny", "Rainy"]
weather = ["Dry", "Wet"]
umbrella = ["Leave", "Take"]
# Joints on (weather, forecast) and (umbrella, forecast). Both forecast
# marginals are [0.5, 0.5].
p_wf = FXTensor(
    [[], [weather, forecast]],
    data=np.array(
        [
            [0.45, 0.05],  # Dry
            [0.05, 0.45],  # Wet
        ]
    ),
)
p_uf = FXTensor(
    [[], [umbrella, forecast]],
    data=np.array(
        [
            [0.40, 0.10],  # Leave
            [0.10, 0.40],  # Take
        ]
    ),
)
joint = FXTensor.conditional_product(p_wf, p_uf, 2)
assert joint.labels == (None, [weather, umbrella, forecast])
assert joint.is_conditionally_independent(2, 3)
weather_umbrella = joint.marginalization(3)
assert weather_umbrella.labels == (None, [weather, umbrella])
assert not weather_umbrella.is_independent(2)

Kernels with a shared domain

The same construction applies when both arguments are kernels A → · with one domain. on still marks the trailing Z factors of each codomain.

import numpy as np
from fxtensor_salmon import FXTensor

context = ["Home", "Away"]
report = ["Dry", "Wet"]
carry = ["Leave", "Take"]
outlook = ["Sunny", "Rainy"]
# Each row is a distribution on report ⊗ outlook, and the outlook marginals agree.
p_report = FXTensor(
    [[context], [report, outlook]],
    data=np.array(
        [
            [[0.36, 0.04], [0.04, 0.56]],  # Home
            [[0.16, 0.16], [0.24, 0.44]],  # Away
        ]
    ),
)
p_carry = FXTensor(
    [[context], [carry, outlook]],
    data=np.array(
        [
            [[0.32, 0.12], [0.08, 0.48]],  # Home
            [[0.30, 0.18], [0.10, 0.42]],  # Away
        ]
    ),
)
kernel = FXTensor.conditional_product(p_report, p_carry, 2)
assert kernel.labels == ([context], [report, carry, outlook])
assert kernel.is_conditionally_independent(2, 3)