Building functions¶
An Expr represents a symbolic value. A Function gives a calculation named
inputs and outputs, and is the unit of composition, differentiation, and code
generation. Its declarations determine both the symbolic Python body and the
structure of numerical calls.
Input and output declarations¶
This function computes a vector's sum and its projection through a matrix:
import numpy as np
import scaly as sc
@sc.function(
sc.G(sc.L("x", 3), sc.L("A", (2, 3))),
sc.G(sc.L("sum", ...), sc.L("projection", 2)),
)
def features(inputs: tuple[sc.Expr, sc.Expr]) -> tuple[sc.Expr, sc.Expr]:
x, A = inputs
return x.sum(), A @ x
total, projected = features((np.array([1.0, 2.0, 3.0]), np.eye(2, 3)))
print(total) # 6.0
print(projected) # [1. 2.]
sc.L declares a leaf, one named input or output array. sc.G declares a
group, represented by a tuple that can contain leaves or other groups.
Here, the Python body receives one tuple containing x and A, and returns a
tuple containing the two results. Numerical calls follow that same structure.
After decoration, features is a Function, not the original Python function.
The annotations describe the symbolic body and let an IDE's type checker compare its argument and return types with the declarations. They are optional and do not change evaluation. Array shapes remain runtime checks.
The shape after a name describes the array:
| Declaration | Meaning |
|---|---|
sc.L("x", 3) |
vector with shape (3,) |
sc.L("A", (2, 3)) |
matrix with two rows and three columns |
sc.L("dt", ()) |
scalar, with no array axes |
sc.L("y", ...) |
output whose shape Scaly infers from the body |
Input shapes must be known when you define the function. For outputs, use ...
unless you want Scaly to check a particular shape. A scalar with shape () and
a one-element vector with shape (1,) are different shapes.
Names such as "projection" let you select outputs for
differentiation. Names need not match Python variable names,
but each input name must be unique, as must each output name.
Python execution and symbolic calculations¶
The decorator runs the Python body once with symbolic values of type sc.Expr.
An expression records a calculation rather than storing its result. For example,
A @ x records a matrix-vector product whose numerical inputs will arrive later.
Use Scaly operations inside the body. Arithmetic such as x * x is elementwise,
A @ x is matrix multiplication, and x.sin() applies sine elementwise. Use
NumPy to prepare data outside the function and sc.const(array) to put fixed
numerical data inside a symbolic calculation.
Python runs when the function is defined, not on each evaluation. This matters for values captured from the surrounding Python scope:
gain = 2.0
@sc.function(sc.L("x", 2), sc.L("y", ...))
def scale(x: sc.Expr) -> sc.Expr:
return gain * x
gain = 10.0
print(scale(np.ones(2))) # [2. 2.]
The multiplication already contains the constant 2.0. Changing the Python
name gain cannot change that expression. A gain that varies between calls
belongs in the input declaration.
A Python loop similarly executes during construction and builds its body once
per iteration. It does not become a loop in the recorded graph. Independent
repetition can instead use vmap.
Symbolic values are not Python conditions¶
A condition based on a symbolic input cannot select a branch at evaluation time.
Nothing prevents using an Expr as a Python truth value:
@sc.function(sc.L("x", ()), sc.L("y", ...))
def branch(x: sc.Expr) -> sc.Expr:
return x if x else sc.const(10.0)
print(branch(np.array(0.0))) # 0.0, not 10.0
The symbolic x is treated as true while the body runs, so only the expression
x is returned. This function has no recorded branch. Scaly has no symbolic
conditional selection.
Supported array operations¶
Expr supports fixed shapes, indexing, broadcasting, and common arithmetic.
The available operations cover part of the NumPy API, and a NumPy operation
missing from the reference is not available on Expr. For example, x.sum() reduces all
elements, but it has no axis argument. The
expression reference lists supported methods.
NumPy arrays are numerical data, so use Scaly operations to construct symbolic
calculations rather than assuming a NumPy function accepts Expr.
Numerical evaluation and symbolic composition¶
Numerical inputs evaluate the function. The first call generates and compiles C. Later calls reuse the compiled code. Results are NumPy arrays, including zero-dimensional arrays for scalar outputs.
Symbolic inputs let you use one function inside another:
@sc.function(sc.L("x", 3), sc.L("square", ...))
def square(x: sc.Expr) -> sc.Expr:
return x * x
@sc.function(sc.L("x", 3), sc.L("energy", ...))
def energy(x: sc.Expr) -> sc.Expr:
return square(x).sum()
print(energy(np.array([1.0, 2.0, 3.0]))) # 14.0
The symbolic calculation records a call to square. Derivatives work across
this boundary, so you can organize a model into functions without manually
combining their bodies. Differentiation and compilation may expand an ordinary
callee into its caller. The standalone
composition example
evaluates this calculation, differentiates it, and exports its C source.
All inputs to a call must be numerical or all must be symbolic. To combine a
symbolic argument with fixed data, wrap the data in sc.const. Explicit
fn.symbolic_call(inputs) and fn.numerical_call(inputs) methods are also
available.
Arrays, tuples, and fixed shapes¶
A single sc.L takes or returns an array directly. Only sc.G introduces a tuple:
A single-output call already returns its array. Writing (squared,) =
square(...) instead iterates over that array. For this length-three result,
Python raises ValueError: too many values to unpack (expected 1). A length-one
result would silently unpack to one element, changing its shape.
Function declarations also fix dimensions. Scaly does not infer a new function shape from each numerical call:
try:
square(np.ones((3, 1)))
except ValueError as error:
print(error)
# square.numerical_call: expected shape (3,) for 'x', got (3, 1)
A three-element column matrix and a vector have the same number of entries,
but different shapes. The same distinction applies to a scalar () and a
one-element vector (1,).
Groups may contain other groups. For example, this input declaration expects
((state, control), (weights, dt)):
inputs = sc.G(
sc.G(sc.L("state", 4), sc.L("control", 2)),
sc.G(sc.L("weights", 10), sc.L("dt", ())),
)
The generated C interface uses the individual arrays in declaration order. Python grouping does not add C buffers. Runtime checks validate the structure and shapes. Type annotations can also catch mismatched tuple structures before running the code.
Regular repetition: vmap¶
Use sc.vmap to apply the same function to independent slices of a longer array.
For example, sum five consecutive groups of three values:
@sc.function(sc.L("x", 3), sc.L("sum", ...))
def reduce3(x: sc.Expr) -> sc.Expr:
return x.sum()
@sc.function(sc.L("xs", 15), sc.L("sums", ...))
def sum_groups(xs: sc.Expr) -> sc.Expr:
return sc.vmap(reduce3, 5, [xs])
print(sum_groups(np.arange(15.0))) # [ 3. 12. 21. 30. 39.]
The second argument, 5, is the number of calls. reduce3 takes three values,
so Scaly splits the 15-element input into five consecutive chunks. The slices
are xs[0:3], xs[3:6], and so on. An input with only three elements would
instead be reused in all five calls.
Supply one array per function input, in declaration order, or use a dictionary
keyed by input name. For example, {"x": xs} means the same as [xs] here.
Each array must contain either all the chunks or exactly one chunk to reuse.
For overlapping or offset windows, specify (array, start, stride) explicitly.
On iteration i, Scaly reads from index start + i * stride, taking as many
elements as that input requires. Thus [(xs, 0, 3)] is an explicit version of
[xs] in this example. A stride of zero reuses the same slice in every call.
Outer arrays must be one-dimensional. Scaly flattens each iteration's output
and concatenates the results into one vector. For functions with several
outputs, output=0 selects the first output, output=1 the second, and so on.
Mapped repetition remains represented as a loop, including in generated derivatives. In optimal
control, this is useful for evaluating dynamics defects at all horizon stages.
The calls must be independent. vmap does not feed one iteration's output into
the next iteration. The loop body need not grow with the number of calls, but
numerical work, input and output storage, and derivative sparsity tables can.
Function metadata¶
print(features.input_names) # ('x', 'A')
print(features.output_shapes) # ((), (2,))
print(sc.render_expr_assembly(features))
The assembly listing shows the recorded operations.
Generated code runs on the host CPU.