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)
4. Print an expression#
The default form is intended for people:
x = nd.Variable("x")
expression = nd.sin(2 * x + 1)
print(expression)
print(expression.to_str())
# sin(2 * x + 1)
Tree form#
print(expression.to_tree())
# Sin (scalar)
# └ Add (scalar)
# ├ Mul (scalar)
# ┆ ├ 2 (scalar)
# ┆ └ x (scalar)
# └ 1 (scalar)
LaTeX form#
latex = expression.to_str(latex=True)
print(latex)
# sin(2 \times x + 1)
The returned string can be passed to a notebook renderer or inserted into a LaTeX document.
Raw form#
The raw form records constructors, nettypes, values, and fitability:
raw = expression.to_str(raw=True)
print(raw)
# Sin(Number(2, "scalar", True) * Variable("x", "scalar") + Number(1, "scalar", True))
restored = nd.parse(raw)
np.testing.assert_allclose(restored.eval({"x": [1.0]}), expression.eval({"x": [1.0]}))
Number formatting#
expression = nd.Number(1.234567) * x
print(expression.to_str(number_format=".3f"))
# 1.235 * x
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]