Examples#

The examples on this page are complete tasks that can be copied into a script or notebook. Use the page table of contents to move between examples.

1. Define variables#

Create one variable directly:

import nd2py as nd

x = nd.Variable("x")

Create several scalar variables at once:

x, y, z = nd.variables("x y z")

Variables read values from a dictionary by name:

data = {
    "x": [1.0, 2.0, 3.0],
    "y": [4.0, 5.0, 6.0],
}

print(x.eval(data))
# [1. 2. 3.]

Use nettype when a variable represents one value per node or edge:

node_state = nd.Variable("node_state", nettype="node")
edge_weight = nd.Variable("edge_weight", nettype="edge")

2. Combine variables into an expression#

Python arithmetic and nd2py functions build a Symbol tree:

x, y = nd.variables("x y")

expression = 2 * x + nd.sin(y) - x**2 / 3
print(expression)
# 2 * x + sin(y) - x ** 2 / 3

Common functions include:

expression = (
    nd.sin(x)
    + nd.cos(y)
    + nd.exp(-x)
    + nd.logabs(y)
    + nd.sqrtabs(x - y)
)

Python numbers embedded in an expression become fitable Number nodes. Use Constant when a value must remain fixed:

fitable_scale = nd.Number(2.0)
fixed_offset = nd.Constant(1.0)
expression = fitable_scale * x + fixed_offset

3. Evaluate an expression with NumPy#

import numpy as np

x, y = nd.variables("x y")
expression = x**2 + 2 * y

data = {
    "x": np.array([1.0, 2.0, 3.0]),
    "y": np.array([4.0, 5.0, 6.0]),
}

result = expression.eval(data)
print(result)
# [ 9. 14. 21.]

NumPy broadcasting applies normally:

expression = x + y
result = expression.eval({
    "x": np.array([[1.0], [2.0]]),
    "y": np.array([10.0, 20.0, 30.0]),
})

assert result.shape == (2, 3)

5. Parse a formula from text#

expression = nd.parse("sin(2 * x) + y ** 2")

result = expression.eval({
    "x": np.array([0.0, 1.0]),
    "y": np.array([2.0, 3.0]),
})

print(result)
# [4.         9.90929743]

Provide predeclared variables when their nettypes matter:

x = nd.Variable("x", nettype="node")
expression = nd.parse("2 * x + 1", variables={"x": x})
assert expression.nettype == "node"

6. Inspect and traverse the expression tree#

x = nd.Variable("x")
expression = nd.sin(2 * x + 1)

for node in expression.iter_preorder():
    print(type(node).__name__)
# Sin
# Add
# Mul
# Number
# Variable
# Number

Postorder is useful for bottom-up computations:

nodes = list(expression.iter_postorder())
assert nodes[-1] is expression

7. Copy and replace a subexpression#

copy() creates an independent expression tree:

copied = expression.copy()
assert copied is not expression

Replace a selected node from a root expression:

x = nd.Variable("x")
y = nd.Variable("y")
expression = nd.sin(x) + 1

x_node = next(
    node for node in expression.iter_preorder()
    if isinstance(node, nd.Variable) and node.name == "x"
)

expression = expression.replace(x_node, y)
print(expression)
# sin(y) + 1

8. Match an expression pattern#

Pattern variables match arbitrary subexpressions:

x = nd.Variable("x")
a = nd.Variable("a")

target = nd.sin(2 * x + 1)
bindings = target.match(nd.sin(a))

print(bindings["a"])
# 2 * x + 1

Repeated pattern names must match the same subexpression:

pattern = a + a
assert (nd.sin(x) + nd.sin(x)).match(pattern) is not None
assert (nd.sin(x) + nd.cos(x)).match(pattern) is None

9. Fit numerical parameters#

Fit the constants in a fixed formula with BFGS:

x = nd.Variable("x")
expression = 0.5 * x + 0.5

X = {"x": np.linspace(-2.0, 2.0, 200)}
y = 3.0 * X["x"] + 2.0

fit = nd.BFGSFit(expression, fold_constant=True)
fit.fit(X, y)

print(fit.expression)
print(fit.loss_)
# 3.0000000371975344 * x + 2.0000000376510387
# 3.2997463432895325e-15

prediction = fit.predict(X)
assert np.mean((prediction - y) ** 2) < 1e-10

Inspect fitted Number nodes:

parameters = [
    node.value
    for node in fit.expression.iter_preorder()
    if isinstance(node, nd.Number) and node.fitable
]

10. Work with network types#

x = nd.Variable("x", nettype="node")
scale = nd.Number(2.0, nettype="scalar")

expression = scale * x
assert expression.nettype == "node"

Map node values to edges using source and target indices:

source_value = nd.sour(x)
target_value = nd.targ(x)

edge_list = ([0, 1, 2], [1, 2, 0])
data = {"x": np.array([10.0, 20.0, 30.0])}

np.testing.assert_array_equal(
    source_value.eval(data, edge_list=edge_list),
    np.array([10.0, 20.0, 30.0]),
)
np.testing.assert_array_equal(
    target_value.eval(data, edge_list=edge_list),
    np.array([20.0, 30.0, 10.0]),
)

11. Fit grouped parameters#

This example fits y = alpha[s] * x, where every value of s selects a different trainable slope.

import numpy as np
import nd2py as nd

data = {
    "s": np.array(["group1", "group1", "group2", "group2", "group3", "group3"]),
    "x": np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]),
    "y": np.array([1.0, 2.0, 6.0, 8.0, 15.0, 18.0]),
}

s = nd.Variable("s")
x = nd.Variable("x")
expression = nd.GroupedParameter(s, default=1.0) * x

fit = nd.BFGSFit(expression)
fit.fit(data, data["y"])

fitted_parameter = fit.expression.operands[0]
print(fitted_parameter.value_dict)
# {'group1': 1.0000001749568208, 'group2': 1.9999999888181819,
#  'group3': 2.99999999086196}

prediction = fit.predict(data)
mse = np.mean((prediction - data["y"]) ** 2)
assert mse < 1e-10

Expected fitted values are approximately:

{
    "group1": 1.0,
    "group2": 2.0,
    "group3": 3.0,
}

12. Define a custom operator#

This example adds an elementwise cube(x) operator without changing any file inside nd2py. A custom Symbol defines the expression-tree structure, while Visitor methods provide its NumPy and printing behavior.

import numpy as np
import nd2py as nd

from nd2py.core.calc.numpy_calc import NumpyCalc
from nd2py.core.converter.string_printer import StringPrinter


class Cube(nd.Symbol):
    """Compute the elementwise cube of one operand."""

    n_operands = 1


def visit_Cube_numpy(self, node, *args, **kwargs):
    x = yield (node.operands[0], args, kwargs)
    return np.power(x, 3)


def visit_Cube_string(self, node, *args, **kwargs):
    x = yield (node.operands[0], args, kwargs)
    if kwargs.get("raw"):
        return f"Cube({x})"
    if kwargs.get("latex"):
        return rf"\left({x}\right)^3"
    return f"cube({x})"


NumpyCalc.visit_Cube = visit_Cube_numpy
StringPrinter.visit_Cube = visit_Cube_string

The Visitor protocol evaluates the operand by yielding it, then applies the custom operation to the value sent back. Registration uses the exact class name: Cube is dispatched to visit_Cube.

The new operator now composes with built-in Symbols:

x = nd.Variable("x")
expression = Cube(x) + 1

print(expression)
# cube(x) + 1

result = expression.eval({
    "x": np.array([1.0, 2.0, 3.0]),
})
print(result)
# [ 2.  9. 28.]

It can also be made available to the parser without modifying nd2py’s global callable table:

parsed = nd.parse(
    "cube(x) + 1",
    callables={"cube": Cube},
)

Direct monkey patching is process-global. Reusable extension packages should perform registration in an explicit setup function and check that an existing visit_Cube method is not being overwritten. The Guide contains a defensive register_visitor helper and discusses which Visitors usually need custom integration.

13. Diagnose numerical exceptions#

Ask NumPy evaluation to identify the exact subexpressions producing non-finite values:

x = nd.Variable("x")
expression = nd.log(x) + nd.inv(x)

result, exceptions = expression.eval(
    {"x": np.array([-1.0, 0.0, 1.0])},
    return_exceptions=True,
)

print(result)
print(*exceptions, sep="\n")
# [nan nan  1.]
# Subexpression log(x) produced non-finite values: 33.33% NaN, 0.00% Inf, 33.33% NegInf
# Subexpression 1 / x produced non-finite values: 0.00% NaN, 33.33% Inf, 0.00% NegInf
# Subexpression log(x) + 1 / x produced non-finite values: 66.67% NaN, 0.00% Inf, 0.00% NegInf

assert len(exceptions) == 3
assert "log(x)" in exceptions[0]
assert "1 / x" in exceptions[1]

Without return_exceptions=True, eval keeps its original return type and returns only the numerical result.

14. Estimate numerical sensitivity with EIC#

The Effective Information Criterion estimates the number of decimal digits lost through numerical sensitivity:

x = nd.Variable("x")

stable_eic = (x + 1).eval_eic({"x": np.array([1.0, 2.0, 3.0])})
unstable_eic, exceptions = (x - x).eval_eic(
    {"x": np.array([1.0, 2.0, 3.0])},
    return_exceptions=True,
)

print(stable_eic)
print(unstable_eic)
print(*exceptions, sep="\n")
# 0.09658807486020385
# inf
# The subexpression x - x exhibits elevated local numerical sensitivity,
# corresponding to an estimated loss of inf decimal digits of precision.

assert np.isfinite(stable_eic)
assert np.isinf(unstable_eic)
assert "x - x" in exceptions[0]