Skip to content

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.

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.

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.

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.

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.

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.
  """
  rows, inverse = np.unique(np.asarray(sparsity.rows, dtype=np.int64), return_inverse=True)
  mask = _incidence((len(rows), sparsity.shape[1]), inverse, np.asarray(sparsity.cols, dtype=np.int64))
  csr, csc = mask.tocsr(), mask.tocsc()
  row_ptr, col_ind = csr.indptr, csr.indices
  col_ptr, row_ind = csc.indptr, csc.indices
  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

sparse_jacobian

sparse_jacobian(expr: Expr, wrt: Expr) -> Expr

Sparse Jacobian: a sparse tensor of shape (expr.size, wrt.size) storing only its structural nonzeros.

wrt must be an independent input from sc.sym or a Function declaration. Takes the structured path when expr is a mapped loop (or a concatenation of them) over wrt or a constant gather of it. It colors 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.

Source code in src/scaly/ad/sparse.py
def sparse_jacobian(expr: Expr, wrt: Expr) -> Expr:
  """Sparse Jacobian: a sparse tensor of shape ``(expr.size, wrt.size)`` storing only its structural nonzeros.

  ``wrt`` must be an independent input from ``sc.sym`` or a Function declaration.
  Takes the structured path when ``expr`` is a mapped loop (or a concatenation of them) over
  ``wrt`` or a constant gather of it. It colors 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.
  """
  return _sparse_jacobian(expr, wrt).leaf()

sparse_hessian

sparse_hessian(expr: Expr, wrt: Expr, *, triangle: Triangle = 'full') -> Expr

Sparse Hessian of a scalar expression, a sparse tensor of shape (wrt.size, wrt.size).

wrt must be an independent input from sc.sym or a Function declaration. triangle selects the entries stored: "full" keeps the whole symmetric pattern, "lower" and "upper" keep one triangle with the diagonal. All of them come from one star-colored JVP batch.

sparse_jacobian_colored

sparse_jacobian_colored(expr: Expr, wrt: Expr) -> Expr

Sparse Jacobian from graph-colored compressed JVPs over the whole pattern.

wrt must be an independent input from sc.sym or a Function declaration.

sparse_jacobian_reference

sparse_jacobian_reference(expr: Expr, wrt: Expr) -> Expr

Reference sparse Jacobian path: build the dense Jacobian and keep its structural 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) -> Expr:
  """Reference sparse Jacobian path: build the dense Jacobian and keep its structural 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 _Compact(sparsity, gather(dense, flat)).leaf()