Before we dive in, let’s be clear about what Optyx is and isn’t:
Optyx is a wrapper around SciPy’s optimization routines (and HiGHS for linear programs). It doesn’t implement any optimization algorithms itself. The value proposition is simple:
You write math-like Python: x**2 + y**2
Optyx builds a symbolic tree and computes gradients automatically
Optyx calls SciPy with your objective, gradient, and constraints
You get a solution
That’s it. No magic. No novel algorithms. Just a convenience layer that trades some overhead for developer productivity.
2 The Journey of a Problem
Let’s follow a simple problem through every step of Optyx’s pipeline:
When you write x**2 + y**2, Python doesn’t compute a number. Instead, Optyx’s operator overloading builds a tree structure:
Add
/ \
Power Power
/ \ / \
x 2 y 2
Here’s how it happens:
from optyx import Variablex = Variable("x")y = Variable("y")# This doesn't compute 0 + 0 = 0# It builds a treeexpr = x**2+ y**2# The tree can be inspectedprint(f"Expression type: {type(expr).__name__}")print(f"Expression: {expr}")print(f"Variables in tree: {[v.name for v in expr.get_variables()]}")
The Variable class defines special methods like __add__, __mul__, __pow__:
# Simplified version of what's in optyxclass Variable:def__add__(self, other):return Add(self, other) # Returns a NEW expression, not a numberdef__pow__(self, other):return Power(self, other)
Each operator creates a new node in the tree. The tree IS the mathematical expression—it can be traversed, analyzed, and differentiated.
3.2 The Overhead
Building the tree requires: - One object allocation per operator - Storing references to child nodes - Type checking and coercion
For n operators, that’s O(n) allocations. This is slower than just computing a number, but it’s a one-time cost that enables everything else.
4 Step 2: Evaluating the Tree
When you call expr.evaluate({'x': 3, 'y': 4}), Optyx walks the tree bottom-up:
Symbolic evaluation adds dispatch and tree-traversal work compared with an equivalent hand-written numerical function. The cost depends on expression structure and whether Optyx can use a vectorized compiled path. End-to-end solver measurements are reported on the versioned Benchmarks page.
5 Step 3: Automatic Differentiation
This is where the tree structure pays off. Each node type knows its own derivative rule:
Node
Derivative Rule
Add(a, b)
∂/∂x = ∂a/∂x + ∂b/∂x
Multiply(a, b)
∂/∂x = (∂a/∂x)·b + a·(∂b/∂x)
Power(a, n)
∂/∂x = n·a^(n-1)·(∂a/∂x)
Variable(x)
∂/∂x = 1, ∂/∂y = 0
Constant(c)
∂/∂x = 0
5.1 Walking for Gradients
To compute ∂(x² + y²)/∂x:
Add → ∂/∂x = ∂(x²)/∂x + ∂(y²)/∂x
/ \
Power Power → ∂(x²)/∂x = 2x·1 = 2x
/ \ / \ ∂(y²)/∂x = 2y·0 = 0
x 2 y 2
Result: ∂/∂x = 2x + 0 = 2x
from optyx import Variablefrom optyx.core.autodiff import gradientx = Variable("x")y = Variable("y")expr = x**2+ y**2# Compute symbolic gradientsgrad_x = gradient(expr, x) # Returns an expression: 2*xgrad_y = gradient(expr, y) # Returns an expression: 2*y# Evaluate at a pointprint(f"∂f/∂x = {grad_x} → at (3,4): {grad_x.evaluate({'x': 3, 'y': 4})}")print(f"∂f/∂y = {grad_y} → at (3,4): {grad_y.evaluate({'x': 3, 'y': 4})}")
∂f/∂x = (Constant(2) * Variable('x')) → at (3,4): 6
∂f/∂y = (Constant(2) * Variable('y')) → at (3,4): 8
5.2 Why This Matters
Without autodiff, you’d write gradients by hand:
# Manual gradient for scipydef objective(vars):returnvars[0]**2+vars[1]**2def gradient(vars): # You write this!return np.array([2*vars[0], 2*vars[1]])
Optyx computes the Rosenbrock gradient automatically:
from optyx import Variablefrom optyx.core.autodiff import gradientx = Variable("x")y = Variable("y")rosenbrock =100*(y - x**2)**2+ (1- x)**2# Symbolic gradients - no manual derivation!grad_x = gradient(rosenbrock, x)grad_y = gradient(rosenbrock, y)# Evaluate at the optimum (1, 1)print(f"∂f/∂x at (1,1): {grad_x.evaluate({'x': 1, 'y': 1})}")print(f"∂f/∂y at (1,1): {grad_y.evaluate({'x': 1, 'y': 1})}")
∂f/∂x at (1,1): 0.0
∂f/∂y at (1,1): 0
5.3 The Overhead
Autodiff requires a second tree walk (for gradients) on top of the evaluation walk. For simple functions, this is slower than hand-written gradients. For complex functions, it’s comparable—and you didn’t have to derive anything.
Approach
Derivative work
Developer Time
Hand-written
Depends on the implementation
Higher for complex functions
Finite differences
Requires additional function evaluations
Low
Optyx autodiff
Exact symbolic rules compiled for the model
Low
Optyx is faster than finite differences and requires zero manual effort.
6 Step 4: Problem Compilation
When you call .solve(), Optyx compiles the problem into a format SciPy understands:
The expression tree is walked to find all Variable nodes:
from optyx import Variablex = Variable("x")y = Variable("y")z = Variable("z")expr = x**2+2*x*y + y**2# z is defined but not usedvariables = expr.get_variables()print(f"Variables in expression: {[v.name for v in variables]}")# z is NOT included - only variables actually in the tree
Variables in expression: ['x', 'y']
This determines the dimensionality of the problem and the order of variables in the solution vector.
You can override this with problem.solve(method="trust-constr").
6.3 The Overhead
Compilation involves: - Tree traversal to discover variables: O(tree size) - Constraint classification: O(number of constraints) - Creating wrapper functions: O(1) but has constant overhead
For a typical problem, compilation takes 1-10 milliseconds. This is a one-time cost—re-solves skip compilation.
7 Step 5: Calling SciPy
Here’s what Optyx actually passes to SciPy (simplified):
from scipy.optimize import minimize# Optyx builds these from your expressions:def objective_fn(x_vec):return expr.evaluate({'x': x_vec[0], 'y': x_vec[1]})def gradient_fn(x_vec): grad_dict = expr.gradient({'x': x_vec[0], 'y': x_vec[1]})return np.array([grad_dict['x'], grad_dict['y']])def constraint_fn(x_vec):return x_vec[0] + x_vec[1] -1# x + y >= 1 becomes x + y - 1 >= 0# Then Optyx calls:result = minimize( fun=objective_fn, x0=np.array([0.5, 0.5]), jac=gradient_fn, method='SLSQP', bounds=[(0, None), (0, None)], constraints={'type': 'ineq', 'fun': constraint_fn})
The actual optimization algorithm—SLSQP, L-BFGS-B, trust-constr, HiGHS—is entirely SciPy’s code. Optyx just prepares the inputs and interprets the outputs.
Optyx’s objective/gradient functions are called at every iteration. A typical problem might need 10-100 iterations, so these functions are called 20-200 times (objective + gradient at each step).
Solution: x=0.5000, y=0.5000
Solve time: 1.09 ms
Iterations: 3
9 Step 7: Caching for Re-solves
First solve compiles everything. Subsequent solves reuse the compiled functions:
from optyx import Variable, Problem, Parameterimport timex = Variable("x", lb=0)y = Variable("y", lb=0)target = Parameter("target", value=1.0)problem = ( Problem() .minimize(x**2+ y**2) .subject_to(x + y >= target))# First solve: includes compilationt0 = time.perf_counter()sol1 = problem.solve()first_time = time.perf_counter() - t0# Re-solves: skip compilationtimes = []for t in [1.5, 2.0, 2.5]: target.set(t) t0 = time.perf_counter() sol = problem.solve() times.append(time.perf_counter() - t0)print(f"First solve: {first_time*1000:.2f} ms")print(f"Re-solves: {[f'{t*1000:.2f} ms'for t in times]}")print(f"Speedup: {first_time / (sum(times)/len(times)):.1f}x")
First solve: 1.65 ms
Re-solves: ['0.70 ms', '0.59 ms', '1.50 ms']
Speedup: 1.8x
This is where Optyx shines: when you solve similar problems repeatedly (scenario analysis, parameter sweeps, real-time optimization), the compilation cost is amortized.
10 Why VectorVariable is Faster
Consider a portfolio problem with 100 assets. The loop approach builds a huge tree:
# Loop approach: 100 + 99 = 199 tree nodes just for the sumweights = [Variable(f"w_{i}", lb=0) for i inrange(100)]total =sum(weights) # 99 Add nodes chained together# Quadratic form: 100*100 + many more = ~20,000 nodesvariance =sum(w[i] * cov[i,j] * w[j] for i inrange(100) for j inrange(100))
The gradient computation is also vectorized: - Loop approach: 100 separate gradient computations - VectorVariable: 1 gradient computation using NumPy arrays
from optyx import VectorVariable, QuadraticFormfrom optyx.core.autodiff import gradientimport numpy as npimport timen =50np.random.seed(42)cov = np.eye(n)w = VectorVariable("w", n, lb=0)qf = QuadraticForm(w, cov)# Symbolic gradients are computed once (vectorized operation)# Then evaluated many times during optimizationw0 = w._variables[0] # Get first variablegrad_wrt_w0 = gradient(qf, w0) # Returns symbolic gradientprint(f"QuadraticForm with {n} variables")print(f"Gradient expression: {type(grad_wrt_w0).__name__}")print(f"(Internally uses matrix operations for O(n) instead of O(n²) evaluation)")
QuadraticForm with 50 variables
Gradient expression: LinearCombination
(Internally uses matrix operations for O(n) instead of O(n²) evaluation)
11 The Trade-offs: Honest Assessment
11.1 Where Optyx Adds Overhead
Operation
Overhead vs Raw SciPy
Why
First solve
Model-dependent
Tree building + compilation
Each f(x) call
Model-dependent
Compiled symbolic evaluation vs direct computation
Each ∇f(x) call
Model-dependent
Generated derivative vs hand-written derivative
Re-solve
Usually near raw SciPy
Cached structure avoids repeated compilation; solver work still remains
11.2 When This Overhead Matters
Matters: Simple problems solved once (use raw SciPy)
Matters: Extremely time-critical real-time systems
Matters: When you need the absolute fastest solution
11.3 When Optyx Wins
Wins: Complex gradients you don’t want to derive
Wins: Problems solved many times (caching pays off)
Wins: Rapid prototyping where developer time > compute time
Wins: Maintainable code that others can understand
12 What Optyx Doesn’t Do
To be completely transparent:
No novel algorithms — All optimization is SciPy/HiGHS
No symbolic simplification — x - x is a tree, not 0
No global optimization — Local methods only (for now)
No parallelization — Single-threaded tree evaluation
No GPU support — CPU only (JAX backend planned)
No mixed-integer beyond LP — MILP via HiGHS, but no MINLP
For these capabilities, use specialized tools: - MILP at scale: Gurobi, CPLEX, or Pyomo - Convex optimization: CVXPY - Global optimization: SciPy’s differential_evolution, dual_annealing - GPU acceleration: JAX, PyTorch optimizers
13 Summary: The Full Pipeline
┌─────────────────────────────────────────────────────────────────────┐
│ Your Code │
│ ──────────────────────────────────────────────────────────────── │
│ x = Variable("x") │
│ problem = Problem().minimize(x**2).solve() │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 1: Expression Tree Building │
│ ──────────────────────────────────────────────────────────────── │
│ Python operators (__add__, __mul__, __pow__) create tree nodes │
│ Overhead: O(n) allocations for n operators │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 2: Problem Compilation │
│ ──────────────────────────────────────────────────────────────── │
│ - Discover variables by walking tree │
│ - Classify constraints (linear, bounds, nonlinear) │
│ - Select solver (HiGHS, SLSQP, L-BFGS-B, trust-constr) │
│ - Compile tree to callable functions │
│ Overhead: 1-10 ms (one-time) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 3: SciPy Optimization │
│ ──────────────────────────────────────────────────────────────── │
│ scipy.optimize.minimize( │
│ fun=compiled_objective, │
│ jac=compiled_gradient, # ← Autodiff! │
│ constraints=compiled_constraints, │
│ bounds=extracted_bounds, │
│ method=selected_solver │
│ ) │
│ Overhead: Near-zero (this is just SciPy) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 4: Solution Packaging │
│ ──────────────────────────────────────────────────────────────── │
│ - Map SciPy result back to named variables │
│ - Package as Solution object │
│ - Cache compiled functions for re-solve │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Your Solution │
│ ──────────────────────────────────────────────────────────────── │
│ solution['x'] # Access by name │
│ solution.objective_value │
│ solution.status │
└─────────────────────────────────────────────────────────────────────┘
14 Next Steps
Quickstart — Build your first problem in 5 minutes
Core Concepts — Deeper dive into Variables, Expressions, Constraints