Differentiation
These functions construct derivatives directly from symbolic expressions. For the usual modelling
interface, including derivatives of a named Function, see Building functions.
The derivatives guide explains gradients, Jacobians, and Hessians.
The result can be any Expr. Each wrt must be an independent input created by sc.sym or a
Function declaration. Computed expressions, including slices and reshapes, and constants raise
ValueError. See Differentiation inputs for
examples and for declaring the inputs of a partial derivative.
Modes
A Jacobian-vector product, jvp, computes a directional derivative without constructing the full
Jacobian. A vector-Jacobian product, vjp, propagates output weights back to the inputs.
jvp_many handles several directions together.
jvp
jvp(expr: Expr, wrt: Expr, seed: Expr) -> Expr
Forward-mode derivative: J(expr, wrt) @ seed, with seed shaped like wrt.
wrt must be an independent input from sc.sym or a Function declaration.
Source code in src/scaly/ad/forward.py
| def jvp(expr: Expr, wrt: Expr, seed: Expr) -> Expr:
"""Forward-mode derivative: ``J(expr, wrt) @ seed``, with ``seed`` shaped like ``wrt``.
``wrt`` must be an independent input from ``sc.sym`` or a Function declaration.
"""
tangent = jvp_many(expr, wrt, seed.reshape((1, *seed.shape)))
if tangent.op == ExprOp.CONST:
assert tangent.value is not None
return Expr.const(tangent.value.reshape(expr.shape), dtype=tangent.type.dtype, lowering=tangent.lowering)
return tangent.reshape(expr.shape)
|
jvp_many
jvp_many(expr: Expr, wrt: Expr, seeds: Expr) -> Expr
Forward mode over seeds shaped (nseed, *wrt.shape), returning (nseed, *expr.shape).
wrt must be an independent input from sc.sym or a Function declaration. One traversal
shares primal work across all seeds. A missing derivative rule raises.
Source code in src/scaly/ad/forward.py
| def jvp_many(expr: Expr, wrt: Expr, seeds: Expr) -> Expr:
"""Forward mode over seeds shaped ``(nseed, *wrt.shape)``, returning ``(nseed, *expr.shape)``.
``wrt`` must be an independent input from ``sc.sym`` or a Function declaration. One traversal
shares primal work across all seeds. A missing derivative rule raises.
"""
_check_wrt(wrt)
if len(seeds.shape) < 1 or seeds.shape[1:] != wrt.shape:
raise ValueError(f"multi-seed JVP expects seeds shape (nseed, *{wrt.shape}), got {seeds.shape}")
if not wrt.type.dtype.is_floating or not expr.type.dtype.is_floating:
raise TypeError("integer and boolean values have no tangent")
if seeds.type.dtype != wrt.type.dtype:
raise TypeError(f"JVP seeds must have primal dtype {wrt.type.dtype}, got {seeds.type.dtype}")
seeds = simplify_cse_fixpoint(seeds)
(ret,) = _pushforward((expr,), {wrt: seeds}, seeds.shape[0])
assert ret is not None
return ret
|
vjp
vjp(outputs: Sequence[Expr], wrts: Sequence[Expr], cotangents: Sequence[Expr]) -> tuple[Expr, ...]
Reverse-mode derivative: one adjoint per entry of wrts, seeded by cotangents.
Each cotangent is shaped like its output. One sweep computes the derivative of a scalar with
respect to every requested input at once, which is why gradients go through here.
Every entry of wrts must be an independent input from sc.sym or a Function declaration.
Results follow wrts order.
Source code in src/scaly/ad/reverse.py
| def vjp(outputs: Sequence[Expr], wrts: Sequence[Expr], cotangents: Sequence[Expr]) -> tuple[Expr, ...]:
"""Reverse-mode derivative: one adjoint per entry of ``wrts``, seeded by ``cotangents``.
Each cotangent is shaped like its output. One sweep computes the derivative of a scalar with
respect to every requested input at once, which is why gradients go through here.
Every entry of ``wrts`` must be an independent input from ``sc.sym`` or a Function declaration.
Results follow ``wrts`` order.
"""
if len(outputs) != len(cotangents):
raise ValueError(f"expected {len(outputs)} cotangents, got {len(cotangents)}")
for wrt in wrts:
_check_wrt(wrt)
adjoints: dict[int, Expr] = {}
nodes = topo(outputs)
expr_ids = {e.id for e in nodes}
dep_memo: dict[tuple[int, int], bool] = {}
def needed(expr: Expr) -> bool:
return any(_depends_on(expr, wrt, dep_memo, through_stops=False) for wrt in wrts)
for out, cot in zip(outputs, cotangents, strict=True):
if out.shape != cot.shape:
raise ValueError(f"cotangent for output shape {out.shape} has shape {cot.shape}")
adjoints[out.id] = cot if out.id not in adjoints else adjoints[out.id] + cot
for expr in reversed(nodes):
cot = adjoints.get(expr.id)
if cot is None or expr.op in {ExprOp.INPUT, ExprOp.CONST, ExprOp.STOP_GRADIENT} or not needed(expr):
continue
if expr.op == ExprOp.VMAP:
for arg, arg_cot in _vmap_vjp(expr, cot, wrts, dep_memo, pullback=vjp):
if arg.id in expr_ids:
adjoints[arg.id] = arg_cot if arg.id not in adjoints else adjoints[arg.id] + arg_cot
continue
active = tuple(i for i, arg in enumerate(expr.args) if needed(arg)) if expr.op in {ExprOp.CALL, *ELEMENTWISE} else None
for arg, arg_cot in zip(expr.args, _local_vjp(expr, cot, active), strict=True):
if arg_cot is not None and arg.id in expr_ids:
adjoints[arg.id] = arg_cot if arg.id not in adjoints else adjoints[arg.id] + arg_cot
return tuple(adjoints.get(wrt.id, zeros_like(wrt)) for wrt in wrts)
|
Holding a value fixed in derivatives
stop_gradient
stop_gradient(value: Any) -> Expr
Return value unchanged in evaluation and treat it as a constant in derivatives.
Forward and reverse derivatives through this expression are zero, as is its Jacobian sparsity.
Other uses of value still contribute their derivatives. Its generated evaluation C is unchanged.
Source code in src/scaly/ir/expr.py
| def stop_gradient(value: Any) -> Expr:
"""Return ``value`` unchanged in evaluation and treat it as a constant in derivatives.
Forward and reverse derivatives through this expression are zero, as is its Jacobian sparsity.
Other uses of ``value`` still contribute their derivatives. Its generated evaluation C is unchanged.
"""
expr = as_expr(value)
return Expr(ExprOp.STOP_GRADIENT, (expr,), expr.type, lowering=expr.lowering)
|
Whole derivatives
jacobian
jacobian(expr: Expr, wrt: Expr) -> Expr
Dense Jacobian d expr / d wrt, shape (expr.size, wrt.size).
wrt must be an independent input from sc.sym or a Function declaration.
Column j is the derivative with respect to wrt[j]. Computed by pushing the whole
identity through forward mode in one batched pass, then simplifying.
Source code in src/scaly/ad/derivatives.py
| def jacobian(expr: Expr, wrt: Expr) -> Expr:
"""Dense Jacobian ``d expr / d wrt``, shape ``(expr.size, wrt.size)``.
``wrt`` must be an independent input from ``sc.sym`` or a Function declaration.
Column ``j`` is the derivative with respect to ``wrt[j]``. Computed by pushing the whole
identity through forward mode in one batched pass, then simplifying.
"""
_check_wrt(wrt)
if wrt.size == 0:
return Expr.const(np.zeros((expr.size, 0), dtype=expr.type.dtype.numpy()), dtype=expr.type.dtype)
seed_arr = np.eye(wrt.size, dtype=wrt.type.dtype.numpy()).reshape((wrt.size, *wrt.shape))
return simplify_cse_fixpoint(jvp_many(expr, wrt, Expr.const(seed_arr, dtype=wrt.type.dtype)).reshape((wrt.size, expr.size)).transpose((1, 0)))
|
gradient
gradient(expr: Expr, wrt: Expr) -> Expr
Gradient of a scalar expr with respect to wrt, as one reverse sweep.
wrt must be an independent input from sc.sym or a Function declaration.
Source code in src/scaly/ad/derivatives.py
| def gradient(expr: Expr, wrt: Expr) -> Expr:
"""Gradient of a scalar ``expr`` with respect to ``wrt``, as one reverse sweep.
``wrt`` must be an independent input from ``sc.sym`` or a Function declaration.
"""
if expr.size != 1:
raise ValueError("gradient expects a scalar expression")
return vjp((expr,), (wrt,), (_ones_like(expr),))[0]
|
hessian
hessian(expr: Expr, wrt: Expr) -> Expr
Second derivatives of a scalar expr: the Jacobian of its gradient.
wrt must be an independent input from sc.sym or a Function declaration.
Source code in src/scaly/ad/derivatives.py
| def hessian(expr: Expr, wrt: Expr) -> Expr:
"""Second derivatives of a scalar ``expr``: the Jacobian of its gradient.
``wrt`` must be an independent input from ``sc.sym`` or a Function declaration.
"""
return jacobian(gradient(expr, wrt).reshape((wrt.size,)), wrt)
|
finite_difference
finite_difference(fun: Any, x: ndarray, eps: float = 1e-06) -> np.ndarray
Approximate the Jacobian of the numerical fun at x by central differences.
Returns shape (output size, x.size). Useful as an independent check of a symbolic derivative.
Source code in src/scaly/ad/derivatives.py
| def finite_difference(fun: Any, x: np.ndarray, eps: float = 1e-6) -> np.ndarray:
"""Approximate the Jacobian of the numerical ``fun`` at ``x`` by central differences.
Returns shape ``(output size, x.size)``. Useful as an independent check of a symbolic derivative.
"""
x = np.asarray(x, dtype=np.float64)
y0 = np.asarray(fun(x), dtype=np.float64).reshape(-1)
jac = np.empty((y0.size, x.size), dtype=np.float64)
flat = x.reshape(-1)
for i in range(flat.size):
xp = flat.copy()
xm = flat.copy()
xp[i] += eps
xm[i] -= eps
jac[:, i] = (np.asarray(fun(xp.reshape(x.shape))).reshape(-1) - np.asarray(fun(xm.reshape(x.shape))).reshape(-1)) / (2 * eps)
return jac
|
Sparsity
A sparsity pattern identifies entries that may be nonzero. Coloring groups derivative directions
that can be evaluated together without mixing their results. See the
sparsity guide for the storage format and examples.
jacobian_sparsity
jacobian_sparsity(expr: Expr, wrt: Expr) -> SparsityPattern
Estimate structural sparsity of d vec(expr) / d vec(wrt).
wrt must be an independent input from sc.sym or a Function declaration.
This is purely symbolic: it tracks element dependencies through the graph without using
numerical values. It is conservative for nonsmooth elementwise ops and block ops, but exact
for the structural/arithmetic subset currently implemented here.
Source code in src/scaly/ad/sparsity.py
| def jacobian_sparsity(expr: Expr, wrt: Expr) -> SparsityPattern:
"""Estimate structural sparsity of ``d vec(expr) / d vec(wrt)``.
``wrt`` must be an independent input from ``sc.sym`` or a Function declaration.
This is purely symbolic: it tracks element dependencies through the graph without using
numerical values. It is conservative for nonsmooth elementwise ops and block ops, but exact
for the structural/arithmetic subset currently implemented here.
"""
_check_wrt(wrt)
return _mask_sparsity(_jac_mask(expr, wrt, {}))
|
column_coloring
column_coloring(sparsity: SparsityPattern) -> tuple[int, ...]
Assign each column a color, greedily, so no two columns sharing a row get the same one.
Columns of one color can be recovered from a single forward pass, so the number of colors is
the number of passes a compact Jacobian costs.
Source code in src/scaly/ad/sparsity.py
| def column_coloring(sparsity: SparsityPattern) -> tuple[int, ...]:
"""Assign each column a color, greedily, so no two columns sharing a row get the same one.
Columns of one color can be recovered from a single forward pass, so the number of colors is
the number of passes a compact Jacobian costs.
"""
row_ptr, col_ind, _ = sparsity.to_csr()
col_ptr, row_ind, _ = sparsity.to_csc()
colors: list[int] = []
for col in range(sparsity.shape[1]):
used = {colors[other] for row in row_ind[col_ptr[col] : col_ptr[col + 1]] for other in col_ind[row_ptr[row] : row_ptr[row + 1]] if other < col}
color = 0
while color in used:
color += 1
colors.append(color)
return tuple(colors)
|
star_coloring
star_coloring(sparsity: SparsityPattern) -> tuple[int, ...]
Greedily star-color a square sparsity pattern in column order.
The pattern is treated as an undirected graph. A valid coloring is proper, and no simple path
of three edges has only two colors. This is the coloring needed to recover a symmetric Hessian
from compressed forward products. It is deliberately not distance-2 coloring.
Source code in src/scaly/ad/sparsity.py
| def star_coloring(sparsity: SparsityPattern) -> tuple[int, ...]:
"""Greedily star-color a square sparsity pattern in column order.
The pattern is treated as an undirected graph. A valid coloring is proper, and no simple path
of three edges has only two colors. This is the coloring needed to recover a symmetric Hessian
from compressed forward products. It is deliberately not distance-2 coloring.
"""
rows, cols = sparsity.shape
if rows != cols:
raise ValueError(f"star coloring requires a square sparsity pattern, got {sparsity.shape}")
neighbors = [set() for _ in range(rows)]
for row, col in zip(sparsity.rows, sparsity.cols, strict=True):
if row != col:
neighbors[row].add(col)
neighbors[col].add(row)
colors = [-1] * rows
for vertex in range(rows):
color = 0
while _star_color_conflicts(vertex, color, colors, neighbors):
color += 1
colors[vertex] = color
return tuple(colors)
|
color_groups
color_groups(colors: tuple[int, ...]) -> tuple[tuple[int, ...], ...]
Invert a coloring into the column indices belonging to each color.
Source code in src/scaly/ad/sparsity.py
| def color_groups(colors: tuple[int, ...]) -> tuple[tuple[int, ...], ...]:
"""Invert a coloring into the column indices belonging to each color."""
if not colors:
return ()
return tuple(tuple(i for i, c in enumerate(colors) if c == color) for color in range(max(colors) + 1))
|
Sparse derivatives
SparseJacobian
dataclass
A sparse derivative: the structural sparsity and the values of its entries, in the same order.
coloring_width is the number of forward-mode directions the values were computed from, or
None when they did not come from a coloring.
Source code in src/scaly/ad/sparse.py
| @dataclass(frozen=True, slots=True)
class SparseJacobian:
"""A sparse derivative: the structural ``sparsity`` and the ``values`` of its entries, in the same order.
``coloring_width`` is the number of forward-mode directions the values were computed from, or
``None`` when they did not come from a coloring.
"""
sparsity: SparsityPattern
values: Expr
coloring_width: int | None = None
_compressed: Expr | None = field(default=None, compare=False, repr=False)
_recovery: np.ndarray | None = field(default=None, compare=False, repr=False)
@property
def flat_indices(self) -> np.ndarray:
"""Row-major positions of the entries in the dense matrix."""
return np.asarray(self.sparsity.rows, dtype=np.int64) * self.sparsity.shape[1] + np.asarray(self.sparsity.cols, dtype=np.int64)
def to_dense(self) -> Expr:
"""Scatter the values into a dense matrix expression."""
return scatter(self.values, self.flat_indices, self.sparsity.shape)
def triangle(self, triangle: Triangle) -> SparseJacobian:
"""Select one triangle from a symmetric sparse matrix without rebuilding its JVP batch."""
triangle = _validate_triangle(triangle)
if triangle == "full":
return self
if self.sparsity.shape[0] != self.sparsity.shape[1]:
raise ValueError(f"triangle selection requires a square sparsity pattern, got {self.sparsity.shape}")
rows = np.asarray(self.sparsity.rows, dtype=np.int64)
cols = np.asarray(self.sparsity.cols, dtype=np.int64)
keep = rows >= cols if triangle == "lower" else rows <= cols
sparsity = SparsityPattern(
self.sparsity.shape,
tuple(int(row) for row in rows[keep]),
tuple(int(col) for col in cols[keep]),
)
source = self._compressed if self._compressed is not None else self.values
recovery = self._recovery[keep] if self._recovery is not None else np.flatnonzero(keep)
values = simplify_cse_fixpoint(gather(source, recovery))
return SparseJacobian(sparsity, values, self.coloring_width, source, recovery)
|
flat_indices
property
Row-major positions of the entries in the dense matrix.
to_dense
Scatter the values into a dense matrix expression.
Source code in src/scaly/ad/sparse.py
| def to_dense(self) -> Expr:
"""Scatter the values into a dense matrix expression."""
return scatter(self.values, self.flat_indices, self.sparsity.shape)
|
triangle
triangle(triangle: Triangle) -> SparseJacobian
Select one triangle from a symmetric sparse matrix without rebuilding its JVP batch.
Source code in src/scaly/ad/sparse.py
| def triangle(self, triangle: Triangle) -> SparseJacobian:
"""Select one triangle from a symmetric sparse matrix without rebuilding its JVP batch."""
triangle = _validate_triangle(triangle)
if triangle == "full":
return self
if self.sparsity.shape[0] != self.sparsity.shape[1]:
raise ValueError(f"triangle selection requires a square sparsity pattern, got {self.sparsity.shape}")
rows = np.asarray(self.sparsity.rows, dtype=np.int64)
cols = np.asarray(self.sparsity.cols, dtype=np.int64)
keep = rows >= cols if triangle == "lower" else rows <= cols
sparsity = SparsityPattern(
self.sparsity.shape,
tuple(int(row) for row in rows[keep]),
tuple(int(col) for col in cols[keep]),
)
source = self._compressed if self._compressed is not None else self.values
recovery = self._recovery[keep] if self._recovery is not None else np.flatnonzero(keep)
values = simplify_cse_fixpoint(gather(source, recovery))
return SparseJacobian(sparsity, values, self.coloring_width, source, recovery)
|
sparse_jacobian
sparse_jacobian(expr: Expr, wrt: Expr) -> SparseJacobian
Compact sparse Jacobian: the structural pattern plus an expression for its nonzeros only.
wrt must be an independent input from sc.sym or a Function declaration.
Takes the structured path when expr is a VMAP (or a concatenation of them) over exactly
wrt, coloring the callee's small local pattern instead of the whole matrix, so the cost
tracks the callee rather than the iteration count. Otherwise falls back to coloring the global
pattern. The value order is the pattern's (rows, cols) order, which is not necessarily
sorted.
Source code in src/scaly/ad/sparse.py
| def sparse_jacobian(expr: Expr, wrt: Expr) -> SparseJacobian:
"""Compact sparse Jacobian: the structural pattern plus an expression for its nonzeros only.
``wrt`` must be an independent input from ``sc.sym`` or a Function declaration.
Takes the structured path when ``expr`` is a ``VMAP`` (or a concatenation of them) over exactly
``wrt``, coloring the callee's small local pattern instead of the whole matrix, so the cost
tracks the callee rather than the iteration count. Otherwise falls back to coloring the global
pattern. The value order is the pattern's ``(rows, cols)`` order, which is not necessarily
sorted.
"""
_check_wrt(wrt)
structured = _sparse_jacobian_structured(expr, wrt)
if structured is not None:
return structured
return sparse_jacobian_colored(expr, wrt)
|
sparse_hessian
sparse_hessian(expr: Expr, wrt: Expr, *, triangle: Triangle = 'full') -> SparseJacobian
Compact sparse Hessian of a scalar expression using one global star-colored JVP batch.
wrt must be an independent input from sc.sym or a Function declaration.
Source code in src/scaly/ad/sparse.py
| def sparse_hessian(expr: Expr, wrt: Expr, *, triangle: Triangle = "full") -> SparseJacobian:
"""Compact sparse Hessian of a scalar expression using one global star-colored JVP batch.
``wrt`` must be an independent input from ``sc.sym`` or a Function declaration.
"""
triangle = _validate_triangle(triangle)
if expr.size != 1:
raise ValueError("sparse_hessian expects a scalar expression")
_check_wrt(wrt)
gradient_expr = cse(simplify(gradient(expr, wrt).reshape((wrt.size,))))
sparsity = _symmetrize_sparsity(jacobian_sparsity(gradient_expr, wrt))
colors = star_coloring(sparsity)
recovery = _star_recovery_indices(sparsity, colors)
return _sparse_jacobian_colored(gradient_expr, wrt, sparsity, colors, recovery).triangle(triangle)
|
sparse_jacobian_colored
sparse_jacobian_colored(expr: Expr, wrt: Expr) -> SparseJacobian
Compact sparse Jacobian values from graph-colored compressed JVPs.
wrt must be an independent input from sc.sym or a Function declaration.
Source code in src/scaly/ad/sparse.py
| def sparse_jacobian_colored(expr: Expr, wrt: Expr) -> SparseJacobian:
"""Compact sparse Jacobian values from graph-colored compressed JVPs.
``wrt`` must be an independent input from ``sc.sym`` or a Function declaration.
"""
_check_wrt(wrt)
expr = cse(expr)
sparsity = jacobian_sparsity(expr, wrt)
colors = column_coloring(sparsity)
return _sparse_jacobian_colored(expr, wrt, sparsity, colors)
|
sparse_jacobian_reference
sparse_jacobian_reference(expr: Expr, wrt: Expr) -> SparseJacobian
Reference compact Jacobian path: build dense J and gather nonzeros.
wrt must be an independent input from sc.sym or a Function declaration.
Source code in src/scaly/ad/sparse.py
| def sparse_jacobian_reference(expr: Expr, wrt: Expr) -> SparseJacobian:
"""Reference compact Jacobian path: build dense ``J`` and gather nonzeros.
``wrt`` must be an independent input from ``sc.sym`` or a Function declaration.
"""
sparsity = jacobian_sparsity(expr, wrt)
dense = jacobian(expr, wrt)
flat = np.asarray(sparsity.rows, dtype=np.int64) * wrt.size + np.asarray(sparsity.cols, dtype=np.int64)
return SparseJacobian(sparsity, gather(dense, flat))
|