Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a8f2751
rename ParameterNode to DataNode
mscroggs Jun 5, 2026
df6bf52
add evaluate method
mscroggs Jun 5, 2026
1d0738d
mypy?
mscroggs Jun 5, 2026
cbb10ea
ruff
mscroggs Jun 5, 2026
a956b83
typeerror
mscroggs Jun 5, 2026
b7b3332
rufff
mscroggs Jun 5, 2026
abbff6b
simplify some graph code
mscroggs Jun 24, 2026
43149c5
Update src/causalprog/graph/node/base.py
mscroggs Jun 25, 2026
6e68264
Update src/causalprog/graph/node/base.py
mscroggs Jun 25, 2026
8f64aa4
Update tests/test_algorithms/test_evaluate.py
mscroggs Jun 25, 2026
752005a
Apply suggestion from @mscroggs
mscroggs Jun 25, 2026
7f1db29
fix tests
mscroggs Jun 25, 2026
5ed79b5
ruff
mscroggs Jun 25, 2026
bb1b0f3
hasattr(..., "shape") instead of type check
mscroggs Jun 25, 2026
2c23e01
let KeyError happen rather than asserting types
mscroggs Jun 25, 2026
2815ff8
mypy?
mscroggs Jun 25, 2026
42a2844
currect type
mscroggs Jun 25, 2026
2e1eb1b
type: ignore
mscroggs Jun 25, 2026
dae7e6b
PGH003
mscroggs Jun 25, 2026
16c04df
Merge branch 'mscroggs/data-node' into mscroggs/rvnodes
mscroggs Jun 25, 2026
5717c16
typing
mscroggs Jun 25, 2026
209b86e
corrections
mscroggs Jun 25, 2026
06d0f69
add check that labels are valid variable names
mscroggs Jun 25, 2026
7700ea5
ruff
mscroggs Jun 25, 2026
29f7679
update tests and labels
mscroggs Jun 25, 2026
ac099fe
Add random variable nodes
mscroggs Jun 25, 2026
30c0216
ruff
mscroggs Jun 25, 2026
13ad547
ruff
mscroggs Jun 25, 2026
7e6e8c1
Merge branch 'main' into mscroggs/rvnodes2
mscroggs Jun 26, 2026
f548150
add assert_is_valid_value
mscroggs Jun 26, 2026
fb9c13c
Update tests/test_node/test_random_variables.py
mscroggs Jun 26, 2026
7a0104c
-> None
mscroggs Jun 26, 2026
83b6951
Merge branch 'main' into mscroggs/rvnodes2
mscroggs Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/causalprog/_abc/labelled.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ def label(self) -> str:
return self._label

def __init__(self, *, label: str) -> None:
if not str.isidentifier(label):
msg = f"Label is not valid Python variable name: {label}"
raise ValueError(msg)
self._label = str(label)
4 changes: 2 additions & 2 deletions src/causalprog/algorithms/do.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def do(graph: Graph, node: str, value: float, label: str | None = None) -> Graph

"""
if label is None:
label = f"{graph.label}|do({node}={value})"
label = f"{graph.label}_do_{node}__" + f"{value}".replace(".", "_")

nodes = {n.label: deepcopy(n) for n in graph.nodes if n.label != node}

Expand All @@ -92,7 +92,7 @@ def do(graph: Graph, node: str, value: float, label: str | None = None) -> Graph

nodes[node] = ConstantNode(label=node, value=value)

g = Graph(label=f"{label}|do[{node}={value}]")
g = Graph(label=f"{label}_do_{node}__" + f"{value}".replace(".", "_"))
for n in nodes.values():
g.add_node(n)

Expand Down
10 changes: 9 additions & 1 deletion src/causalprog/graph/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
"""Creation and storage of graphs."""

from .graph import Graph
from .node import ComponentNode, ConstantNode, DataNode, DistributionNode, Node
from .node import (
ComponentNode,
ConstantNode,
ContinuousRandomVariableNode,
DataNode,
DiscreteRandomVariableNode,
DistributionNode,
Node,
)
1 change: 1 addition & 0 deletions src/causalprog/graph/node/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
from .constant import ConstantNode
from .data import DataNode
from .distribution import DistributionNode
from .random_variables import ContinuousRandomVariableNode, DiscreteRandomVariableNode
8 changes: 4 additions & 4 deletions src/causalprog/graph/node/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,18 @@
def _to_string(indices: int | slice | tuple[int | slice, ...]) -> str:
"""Convert getitem indices to a string."""
if isinstance(indices, tuple):
return ", ".join(_to_string(i) for i in indices)
return "_".join(_to_string(i) for i in indices)
if isinstance(indices, int):
return f"{indices}"
if isinstance(indices, slice):
s = ""
if indices.start is not None:
s += f"{indices.start}"
s += ":"
s += "__"
if indices.stop is not None:
s += f"{indices.stop}"
if indices.step is not None:
s += f":{indices.step}"
s += f"__{indices.step}"
return s
e = f"Invalid indices: {indices}"
raise TypeError(e)
Expand Down Expand Up @@ -91,7 +91,7 @@ def __getitem__(self, indices: int | slice | tuple[int | slice, ...]) -> Node:
self.label,
indices,
shape=shape,
label=f"{self.label}[{_to_string(indices)}]",
label=f"{self.label}_{_to_string(indices)}",
)

@abstractmethod
Expand Down
150 changes: 150 additions & 0 deletions src/causalprog/graph/node/random_variables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""Graph nodes representing random variables."""

import inspect
import typing
from abc import abstractmethod

import jax
import numpy as np
import numpy.typing as npt
from typing_extensions import override

from .base import Node


class RandomVariableNode(Node):
"""A node containing a random variable (RV)."""

def __init__(
self,
*,
shape: tuple[int, ...] = (),
label: str,
compute: typing.Callable | None = None,
) -> None:
"""
Initialise.

Args:
shape: The shape of the output of the RV
label: A unique label to identify the node
compute: A function to compute node's value from given values of parents

"""
super().__init__(label=label, shape=shape)
if compute is None:
self._parents = []
else:
self._parents = list(inspect.signature(compute).parameters.keys())
Comment thread
willGraham01 marked this conversation as resolved.
self._compute = compute

@override
def sample(
self,
parameter_values: dict[str, float],
sampled_dependencies: dict[str, npt.ArrayLike],
samples: int,
*,
rng_key: jax.Array,
) -> npt.ArrayLike:
raise NotImplementedError

@override
def evaluate(
self,
**given_values: float | npt.NDArray[float],
) -> float | npt.NDArray[float]:
if self.label in given_values:
value = given_values[self.label]
self.assert_is_valid_value(value)
return value

if self._compute is None:
msg = f"Missing input for node: {self.label}."
raise ValueError(msg)
Comment thread
willGraham01 marked this conversation as resolved.
return self._compute(**{p: given_values[p] for p in self._parents})
Comment thread
mscroggs marked this conversation as resolved.

@override
@property
def parents(self) -> list[str]:
return self._parents

@abstractmethod
def is_valid_value(self, value: float | npt.NDArray[float]) -> bool:
"""Check if a value is valid for this node."""

def assert_is_valid_value(self, value: float | npt.NDArray[float]) -> None:
"""Check if a value is valid for this node."""
if not self.is_valid_value(value):
msg = (
f"Invalid value for {self.__class__.__name__}: "
f"{self.label} cannot be {value}"
)
raise ValueError(msg)
if self.shape != (value.shape if hasattr(value, "shape") else ()):
msg = f"Invalid value for node: {self.label}"
raise ValueError(msg)


class ContinuousRandomVariableNode(RandomVariableNode):
"""A node containing a continuous random variable (RV)."""

@override
def __repr__(self) -> str:
return f'ContinuousRandomVariableNode(label="{self.label}")'

@override
def is_valid_value(self, value: float | npt.NDArray[float]) -> bool:
return True

@override
def copy(self) -> Node:
return ContinuousRandomVariableNode(
shape=self.shape, label=self.label, compute=self._compute
)


class DiscreteRandomVariableNode(RandomVariableNode):
"""A node containing a discrete random variable (RV)."""

def __init__(
self,
*,
values: list[float] | list[npt.NDArray[float]],
shape: tuple[int, ...] = (),
label: str,
compute: typing.Callable | None = None,
) -> None:
"""
Initialise.

Args:
shape: The shape of the output of the RV
label: A unique label to identify the node
compute: A function to compute node's value from given values of parents

"""
super().__init__(label=label, shape=shape, compute=compute)
self._values = values

@property
def possible_values(self) -> list[float] | list[npt.NDArray[float]]:
"""The values that this RV can take."""
return self._values

@override
def __repr__(self) -> str:
return f'DiscreteRandomVariableNode(label="{self.label}")'

@override
def is_valid_value(self, value: float | npt.NDArray[float]) -> bool:
return any(np.allclose(v, value) for v in self._values)

@override
def copy(self) -> Node:
return DiscreteRandomVariableNode(
values=self._values,
shape=self.shape,
label=self.label,
compute=self._compute,
)
36 changes: 36 additions & 0 deletions tests/test__abc/test_labelled.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import pytest

from causalprog._abc.labelled import Labelled


@pytest.mark.parametrize(
"label",
[
"1",
" ",
"a b",
"0a",
"a.b",
"a-b",
"a+b",
"a*b",
"a/b",
],
)
def test_invalid_label(label, raises_context):
with raises_context(ValueError("Label is not valid Python variable name")):
Labelled(label=label)


@pytest.mark.parametrize(
"label",
[
"a",
"A",
"a0",
"a_b",
"_a",
],
)
def test_valid_label(label):
Labelled(label=label)
6 changes: 3 additions & 3 deletions tests/test_graph/test_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@ def test_component_node(param_values):
graph.add_node(graph.get_node("X")[1, :, 2])

assert graph.get_node("X").shape == (3, 1, 4)
assert graph.get_node("X[0]").shape == (1, 4)
assert graph.get_node("X[1, 0, 2]").shape == ()
assert graph.get_node("X[1, :, 2]").shape == (1,)
assert graph.get_node("X_0").shape == (1, 4)
assert graph.get_node("X_1_0_2").shape == ()
assert graph.get_node("X_1____2").shape == (1,)
2 changes: 1 addition & 1 deletion tests/test_graph/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def test_model_extension(
parameters={"loc": "mean"},
constant_parameters={"scale": 1.0},
)
one_normal_graph = Graph(label="One normal")
one_normal_graph = Graph(label="One_normal")
one_normal_graph.add_edge(mean, x)

def extended_model(*, cov2, **parameter_values):
Expand Down
35 changes: 35 additions & 0 deletions tests/test_node/test_random_variables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import numpy as np

from causalprog.graph import (
ContinuousRandomVariableNode,
DiscreteRandomVariableNode,
Graph,
)


def test_random_variable_node():
node = ContinuousRandomVariableNode(label="X")
assert np.isclose(node.evaluate(X=2.0), 2.0)


def test_missing_input(raises_context):
node = ContinuousRandomVariableNode(label="X")

with raises_context(ValueError("Missing input for node")):
node.evaluate()


def test_invalid_discrete_node_value(raises_context):
node = DiscreteRandomVariableNode(label="Y", values=[-0.5, 0.0, 0.5])
with raises_context(ValueError("Invalid value for DiscreteRandomVariableNode")):
node.evaluate(Y=-1.0)


def test_evaluate_down_graph():
graph = Graph(label="G")

graph.add_node(ContinuousRandomVariableNode(label="x"))
graph.add_node(DiscreteRandomVariableNode(label="y", values=[-0.5, 0.0, 0.5]))
graph.add_node(ContinuousRandomVariableNode(label="z", compute=lambda x, y: x + y))

assert np.isclose(graph.get_node("z").evaluate(x=3.0, y=-0.5), 2.5)
2 changes: 1 addition & 1 deletion tests/test_node/test_vector_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@ def test_get_component_node(rng_key):
assert s0.shape == (100, 5)
assert node[:, 1].sample({}, {"X": s}, 100, rng_key=rng_key).shape == (100, 4)
assert node[0, 1].sample({}, {"X": s}, 100, rng_key=rng_key).shape == (100,)
assert node[0][1].sample({}, {"X": s, "X[0]": s0}, 100, rng_key=rng_key).shape == (
assert node[0][1].sample({}, {"X": s, "X_0": s0}, 100, rng_key=rng_key).shape == (
100,
)
Loading