Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Part 1: Conceptual Foundations - Why JAX Requires Functional Programming

Pure Functions, Immutability, and Computational Graphs | Computing the Universe | ASTR 596

San Diego State University

Prerequisites: Overview completed


Learning Outcomes

By the end of Part 1, you will be able to:


Roadmap: The Conceptual Foundation

Priority: 🔴 Essential

Core Question: Why does JAX require such strange constraints? (No mutation, explicit control flow, pure functions)

The Answer: These constraints aren’t arbitrary — they’re what make transformations possible.

This part builds your mental model before you write JAX code. Understanding WHY functional programming matters enables you to:

Structure:

  1. OOP vs Functional — Two ways to organize code (you know OOP from this semester)

  2. Pure Functions — The foundation everything builds on

  3. Control Flow — Why Python if/for break, what JAX provides instead

  4. Computational Graphs — How JAX “sees” your code

  5. Transformations — What JAX actually does

  6. Synthesis — Why constraints = capabilities


1.1: Programming Paradigms — OOP vs Functional

Priority: 🔴 Essential

You Already Know Object-Oriented Programming

Earlier this semester, you built classes like Star in your projects:

class Star:
    """Object-oriented design (what you've been learning)."""

    def __init__(self, mass):
        self.mass = mass          # State: data stored in object
        self.age = 0.0            # State: changes over time
        self.radius = None        # State: computed and stored

    def evolve(self, dt):
        """Evolve star forward in time (mutation!)."""
        self.age += dt            # Mutation: change internal state
        self.radius = self._compute_radius()  # Mutation: update state

    def _compute_radius(self):
        """Compute radius based on current mass and age (ZAMS approximation)."""
        # Mass-radius relation for main sequence stars (solar units)
        if self.mass < 1.0:
            radius = self.mass**0.8  # Low-mass stars
        else:
            radius = self.mass**0.57  # High-mass stars
        return radius

    def compute_luminosity(self):
        """Method: operates on internal state."""
        return 4 * np.pi * self.radius**2 * self.effective_temperature**4

Key characteristics of OOP:

This is intuitive! Objects model real-world entities (stars) that change over time. You call methods, the object’s state updates, everything makes sense.

What Is Functional Programming?

Functional programming is a completely different paradigm:

def evolve_star(star_state, dt):
    """
    Functional design (what JAX requires).

    Takes: immutable star_state dict, time step
    Returns: NEW star_state dict (doesn't modify input)
    """
    new_age = star_state['age'] + dt
    new_radius = compute_radius(star_state['mass'], new_age)

    # Return NEW dict, original star_state unchanged
    return {
        'mass': star_state['mass'],      # Unchanged
        'age': new_age,                   # New value
        'radius': new_radius              # New value
    }

def compute_radius(mass, age):
    """
    Pure function: same inputs → always same output.

    Simplified main-sequence stellar radius evolution (CGS units).
    Based on R ∝ M^0.8 scaling with slow contraction over lifetime.

    Args:
        mass: stellar mass [M☉]
        age: stellar age [years]

    Returns:
        radius: stellar radius [cm]
    """
    # Main-sequence radius (R ∝ M^0.8 for low mass, M^0.57 for high mass)
    R_sun = 6.96e10  # Solar radius [cm]
    if mass < 1.0:
        R_MS = R_sun * mass**0.8   # Low-mass stars
    else:
        R_MS = R_sun * mass**0.57  # High-mass stars

    # Main-sequence lifetime (τ_MS ∝ M^-2.5)
    tau_MS = 1e10 * (mass)**(-2.5)  # years

    # Simple contraction with age: R(t) = R_MS * (1 - 0.05 * t/τ_MS)
    # Stars slowly contract on main sequence (5% over lifetime)
    radius = R_MS * (1.0 - 0.05 * age / tau_MS)
    return radius

def compute_luminosity(radius, temperature):
    """Pure function: no hidden state, no side effects."""
    return 4 * np.pi * radius**2 * temperature**4

Key characteristics of functional programming:

Side-by-Side Comparison

AspectOOP (Your Star Class)Functional (JAX Style)
DataMutable state in objectImmutable values
Time evolutionself.age += dtnew_state = evolve(old_state, dt)
FunctionsMethods modify selfPure functions return new values
Callingstar.evolve(dt) changes starnew_star = evolve(star, dt) preserves star
PhilosophyObjects change over timeData transforms through functions

Why This Feels Weird

If you’re thinking “functional programming seems unnecessarily complicated,” you’re not alone. Your brain has been trained on:

Functional programming requires a different mental model:

This will feel awkward for a few weeks. Then it will click. Trust the process.

Example: Simulating 10 Time Steps

OOP way (mutation):

star = Star(mass=1.0)  # Create object with state

for _ in range(10):
    star.evolve(dt)    # Mutate star's internal state each step
    print(star.age, star.radius)  # State keeps changing

Functional way (transformation):

star_state = {'mass': 1.0, 'age': 0.0, 'radius': None}  # Initial state

for _ in range(10):
    star_state = evolve_star(star_state, dt)  # Transform → new state
    print(star_state['age'], star_state['radius'])

In the functional version:

Why JAX Cares About This

JAX transformations require functional style. Here’s why:

  1. JIT compilation needs to know data flow at compile time

    • Mutation makes data flow implicit (star.age could change anywhere)

    • Pure functions make data flow explicit (inputs → outputs)

  2. Automatic differentiation needs to track dependencies

    • If star.radius can be modified from anywhere, how do we track gradients?

    • Pure functions: clear dependency graph

  3. Vectorization (vmap) needs independent computations

    • If functions modify shared state, they can’t run in parallel

    • Pure functions are independent by definition

The paradigm shift:

Practical JAX Example

Here’s actual JAX code for N-body simulation:

import jax.numpy as jnp

# OOP style (would break JAX!)
class Particle:
    def __init__(self, position, velocity):
        self.position = position  # ❌ Mutable state
        self.velocity = velocity  # ❌ Mutable state

    def update(self, force, dt):
        self.velocity += force * dt  # ❌ In-place mutation
        self.position += self.velocity * dt  # ❌ Breaks JAX

# Functional style (JAX-compatible!)
def update_particle(state, force, dt):
    """
    Pure function: (state, force, dt) → new_state
    No mutation, clear data flow.
    """
    pos, vel = state
    new_vel = vel + force * dt       # ✅ Create new array
    new_pos = pos + new_vel * dt     # ✅ Create new array
    return (new_pos, new_vel)        # ✅ Return new state

Why the functional version works with JAX:

Connection to Your Learning This Semester

You learned OOP because it’s:

You’re now learning functional programming because it’s:

Both are valuable. You’ll use OOP for structuring your overall project (Package class hierarchy) and functional programming for the computational core (JAX-transformed functions).

Key Takeaway

Functional programming isn’t “better” than OOP — they’re tools for different jobs.

By Project 5, you’ll naturally write:

The mental shift happens in the next few weeks. Be patient with yourself.


1.2: Pure Functions — The Foundation of JAX

Priority: 🔴 Essential

What Is a Pure Function?

A pure function has two properties:

  1. Deterministic: Same inputs → always same outputs (no randomness, no hidden state)

  2. No side effects: Doesn’t modify anything outside its scope (no mutation, no I/O, no global state changes)

Examples: Pure vs Impure

✅ Pure Functions

def add(x, y):
    """Pure: same inputs → same output, no side effects."""
    return x + y

def gravitational_force(m1, m2, r, G=6.67e-8):
    """Pure: deterministic physics calculation (CGS units)."""
    return G * m1 * m2 / r**2

def compute_energy(positions, velocities, masses):
    """Pure: all inputs explicit, output deterministic."""
    kinetic = 0.5 * jnp.sum(masses * jnp.sum(velocities**2, axis=1))
    potential = compute_potential(positions, masses)
    return kinetic + potential

Why these are pure:

❌ Impure Functions

# Impure: Global state (hidden dependency)
counter = 0
def impure_add(x, y):
    global counter
    counter += 1  # Side effect: modifies global state
    return x + y  # Output depends on when you call it (if you print counter)

# Impure: Non-deterministic
import random
def impure_random():
    return random.random()  # Different output every call!

# Impure: I/O (side effect)
def impure_log(x):
    print(f"Computing for x={x}")  # Side effect: prints to console
    with open('log.txt', 'a') as f:  # Side effect: modifies file
        f.write(f"{x}\n")
    return x ** 2

# Impure: Mutation
def impure_accumulate(array, value):
    array.append(value)  # Side effect: modifies input array
    return array  # Violates immutability

# Impure: Time-dependent
import time
def impure_timestamp():
    return time.time()  # Different output every call!

# Impure: Hidden state
class StatefulCalculator:
    def __init__(self):
        self.history = []  # Hidden state

    def add(self, x, y):
        result = x + y
        self.history.append(result)  # Side effect: modifies self
        return result

Why these are impure:

Why Purity Matters for JAX

JAX transformations require pure functions. Here’s why:

1. JIT Compilation Needs Determinism

When JAX JIT-compiles a function, it traces it once with abstract values:

@jax.jit
def f(x):
    return x ** 2

JAX’s compilation:

  1. Call f with “tracer” (abstract value, knows shape/dtype, not actual value)

  2. Record all operations: square(x)

  3. Compile graph to optimized machine code

  4. Reuse compiled code for all future calls

If your function is impure, the compiled graph won’t match reality:

counter = 0

@jax.jit
def impure_f(x):
    global counter
    counter += 1  # Side effect!
    return x + counter

# First call: counter=0, compiles x + 0
result1 = impure_f(5.0)  # Returns 5.0

# Second call: uses cached compiled code (x + 0), but counter=1 now!
result2 = impure_f(5.0)  # Still returns 5.0 (not 6.0!)

The compiled graph is frozen—it doesn’t re-execute Python, just the compiled operations.

2. Automatic Differentiation Needs Clear Dependencies

Autodiff builds a computational graph by tracking dependencies:

def f(x):
    y = x ** 2
    z = jnp.sin(y)
    return z

# JAX builds graph:
# x → [square] → y → [sin] → z
# Backward pass: dz/dx = dz/dy * dy/dx

If you have hidden dependencies, JAX can’t track them:

global_constant = 5.0

def impure_f(x):
    return x * global_constant  # Hidden dependency!

# JAX can't differentiate w.r.t. global_constant
# It only sees: f(x) = x * [some constant]
grad_f = jax.grad(impure_f)  # Only d/dx, missing d/d(global_constant)

3. Vectorization (vmap) Needs Independence

vmap parallelizes computations over a batch dimension:

def f(x):
    return x ** 2

# Vectorize: apply f to each element independently
batched_f = jax.vmap(f)
batched_f(jnp.array([1, 2, 3]))  # [1, 4, 9]

If functions share mutable state, they can’t be parallelized:

accumulator = 0.0

def impure_f(x):
    global accumulator
    accumulator += x  # Race condition in parallel execution!
    return accumulator

# vmap would have race conditions—undefined behavior

The Mutation Problem in Detail

This is the #1 stumbling block for newcomers:

# NumPy style (mutation)
def update_positions_numpy(positions, velocities, dt):
    positions += velocities * dt  # ❌ In-place mutation
    return positions

# This breaks JAX! positions array is modified in-place
# JAX can't track this properly for autodiff/jit

JAX solution: Explicit updates that return new arrays:

# JAX style (functional)
def update_positions_jax(positions, velocities, dt):
    return positions + velocities * dt  # ✅ New array created

# For more complex updates, JAX provides .at[] syntax:
def update_element_jax(array, index, value):
    return array.at[index].set(value)  # ✅ Returns new array

# Example:
x = jnp.array([1, 2, 3, 4, 5])
y = x.at[2].set(99)  # y = [1, 2, 99, 4, 5], x unchanged

Common “Impure” Patterns and JAX Equivalents

Impure Pattern (breaks JAX)Pure Pattern (JAX-compatible)
x[i] = new_valuex = x.at[i].set(new_value)
x += deltax = x + delta
list.append(item)list = list + [item] (or use jnp.concatenate)
if random.random() > 0.5:key, subkey = jax.random.split(key); if jax.random.uniform(subkey) > 0.5:
print(x)Use jax.debug.print(x) sparingly

Testing for Purity: The Substitution Test

A function is pure if you can replace the function call with its return value without changing behavior:

# Pure: this substitution is always valid
def f(x):
    return x ** 2

y = f(3) + f(3)  # Can be optimized to:
y = 9 + 9

# Impure: substitution changes meaning
counter = 0
def g(x):
    global counter
    counter += 1
    return x

y = g(3) + g(3)  # counter increments twice
y = 3 + 3        # counter doesn't increment—different behavior!

This is called referential transparency: pure functions can be replaced by their values.

Practical Exercise: Identify Purity

# Which of these are pure?

def func1(x, y):
    return x + y

def func2(array):
    return jnp.sum(array)

def func3(x):
    print(f"Computing {x}")
    return x ** 2

def func4(x, external_param=5):
    return x * external_param

params = {'scale': 5}
def func5(x):
    return x * params['scale']

def func6(x):
    return jnp.linalg.norm(x)

cache = {}
def func7(x):
    if x in cache:
        return cache[x]
    result = expensive_computation(x)
    cache[x] = result
    return result

Answers:

Key Takeaway

Purity is JAX’s foundation.

By Project 5, checking for purity will be second nature. For now, when in doubt:


1.3: Control Flow Constraints — Why if and for Break JAX

Priority: 🔴 Essential

The Problem: Data-Dependent Control Flow

Consider this innocent-looking function:

def f(x):
    if x > 0:
        return x ** 2
    else:
        return -x

# Works fine in NumPy:
print(f(5))   # 25
print(f(-5))  # 5

Try to JIT-compile it:

@jax.jit
def f_jit(x):
    if x > 0:  # ❌ This will fail!
        return x ** 2
    else:
        return -x

f_jit(5.0)  # ConcretizationError!

Error:

ConcretizationError: Abstract tracer value encountered where concrete value expected.
The error occurred while tracing the function f_jit for jit compilation.

What happened?

Understanding JIT Compilation and Tracing

When JAX JIT-compiles a function:

  1. Tracing phase: JAX calls your function with abstract values (tracers)

    • Tracers have shape and dtype: ShapedArray(shape=(), dtype=float32)

    • Tracers don’t have concrete values: Can’t evaluate x > 0

  2. Graph building: JAX records operations to build computational graph

  3. Compilation: Graph → optimized machine code (XLA)

  4. Execution: Compiled code runs with actual values

The problem: if x > 0: requires knowing x’s value, but during tracing we only have its shape/dtype!

What Is Data-Dependent Control Flow?

Data-dependent: Control flow decision depends on runtime values

# Data-dependent (value of x determines path)
if x > 0:  # Depends on x's value at runtime
    ...

# Data-dependent (loop count depends on value)
for i in range(n):  # If n is a traced variable, this fails
    ...

# Data-dependent (while loop with data condition)
while x > tolerance:  # Condition depends on runtime value
    ...

Not data-dependent: Control flow known at compile time

# Static (shape-dependent, okay)
if x.shape[0] > 10:  # Shape is known during tracing
    ...

# Static (hardcoded, okay)
for i in range(100):  # Loop count is constant
    ...

# Static (shape-based, okay)
for i in range(x.shape[0]):  # Shape known at tracing
    ...

Why This Matters for JIT

JAX needs to compile ONE graph that works for ALL possible input values:

def f(x):
    if x > 0:
        return x ** 2  # Graph A
    else:
        return -x      # Graph B

# JAX can't know which graph to compile!
# x could be positive or negative at runtime

If JAX picked one branch during tracing:

JAX refuses to guess—throws ConcretizationError instead.

JAX’s Solution: jax.lax.cond

JAX provides functional control flow primitives that compile both branches:

import jax
import jax.lax as lax

def f_jax(x):
    # lax.cond compiles both branches, selects at runtime
    return lax.cond(
        x > 0,              # Predicate (boolean)
        lambda x: x ** 2,   # True branch (function)
        lambda x: -x,       # False branch (function)
        x                   # Operand (passed to selected branch)
    )

# Now JIT works!
f_jit = jax.jit(f_jax)
print(f_jit(5.0))   # 25.0
print(f_jit(-5.0))  # 5.0

How lax.cond works:

  1. During tracing: Compiles both branches

  2. At runtime: Evaluates predicate, selects which branch output to return

  3. Both branches are in the compiled graph—no runtime Python interpretation

Syntax:

lax.cond(predicate, true_fun, false_fun, operand)
#        ↑          ↑          ↑          ↑
#        bool       callable   callable   argument(s)

Loops: Why for and while Are Tricky

Similar problem with loops:

@jax.jit
def sum_until_large(n):
    total = 0
    for i in range(n):  # ❌ n is traced variable
        total += i
    return total

# ConcretizationError: can't determine loop count at compile time

JAX solutions:

1. jax.lax.fori_loop — Simple fixed-count loops

def sum_until_large_jax(n):
    def body_fun(i, total):
        return total + i

    return lax.fori_loop(
        0,          # Start index
        n,          # End index (exclusive)
        body_fun,   # Body: (index, carry) → new_carry
        0           # Initial carry value
    )

sum_jit = jax.jit(sum_until_large_jax)
print(sum_jit(10))  # 45 = 0+1+2+...+9

2. jax.lax.scan — Loops with accumulation

More powerful: accumulate values + optionally collect outputs:

def cumulative_sum_jax(array):
    def scan_fun(carry, x):
        new_carry = carry + x
        output = new_carry  # What to collect
        return new_carry, output

    final_carry, outputs = lax.scan(
        scan_fun,        # (carry, x) → (new_carry, output)
        0,               # Initial carry
        array            # Sequence to scan over
    )
    return final_carry, outputs

# Example:
result, intermediate = cumulative_sum_jax(jnp.array([1, 2, 3, 4, 5]))
# result = 15 (final sum)
# intermediate = [1, 3, 6, 10, 15] (cumulative sums)

3. jax.lax.while_loop — Conditional loops

def newton_method_jax(f, df, x0, tolerance=1e-6):
    def cond_fun(state):
        x, error = state
        return error > tolerance  # Continue while error > tolerance

    def body_fun(state):
        x, _ = state
        x_new = x - f(x) / df(x)
        error = jnp.abs(x_new - x)
        return (x_new, error)

    init_state = (x0, 1.0)  # (initial x, large initial error)
    final_state = lax.while_loop(cond_fun, body_fun, init_state)
    return final_state[0]

Quick Reference: Control Flow Patterns

PatternNumPy / PythonJAX Equivalent
If-elseif cond: ... else: ...lax.cond(predicate, true_fn, false_fn, operand)
Element-wise conditionalnp.where(cond, x, y)jnp.where(cond, x, y) ✅ (works fine!)
Fixed loopfor i in range(n):lax.fori_loop(start, end, body, init)
Accumulating loopfor x in array:lax.scan(fun, init, xs)
While loopwhile cond:lax.while_loop(cond_fun, body_fun, init)

When Do You Actually Need lax Functions?

Good news: Many cases work without lax!

✅ These work fine (no lax needed):

# Element-wise conditionals (jnp.where)
result = jnp.where(x > 0, x**2, -x)  # ✅ Works!

# Shape-based control flow
if x.ndim == 2:  # ✅ Shape known at tracing
    ...

# Static loops (hardcoded range)
for i in range(10):  # ✅ Constant loop count
    ...

# Shape-based loops
for i in range(x.shape[0]):  # ✅ Shape known
    ...

❌ These need lax:

# Value-dependent conditionals
if x > threshold:  # ❌ Use lax.cond

# Value-dependent loop counts
for i in range(n):  # ❌ Use lax.fori_loop (if n is traced)

# While loops with data-dependent conditions
while error > tolerance:  # ❌ Use lax.while_loop

Practical Example: N-body Timesteps

# NumPy style (won't JIT compile if n_steps is dynamic)
def integrate_numpy(state, forces_fn, dt, n_steps):
    for step in range(n_steps):  # Breaks if n_steps is traced
        forces = forces_fn(state)
        state = update_state(state, forces, dt)
    return state

# JAX style (JIT-compilable)
def integrate_jax(state, forces_fn, dt, n_steps):
    def step_fn(carry, _):
        state = carry
        forces = forces_fn(state)
        new_state = update_state(state, forces, dt)
        return new_state, None  # (new_carry, output)

    final_state, _ = lax.scan(step_fn, state, jnp.arange(n_steps))
    return final_state

Why These Constraints?

It’s all about compilation:

Trade-off:

Key Takeaway (Conceptual Understanding)

JAX can’t trace through Python control flow that depends on data values.

Why?

Solution?

In Part 2, you’ll learn the technical details and practical patterns. For now, understand the why: JAX’s constraints enable compilation, and compilation enables speed.


1.4: Computational Graphs — The Mental Model

Priority: 🟡 Important

How Does JAX “See” Your Code?

When you write a function, you see Python code. When JAX sees your function, it sees a computational graph—a directed acyclic graph (DAG) of operations.

Understanding this mental model is crucial for:

What Is a Computational Graph?

A computational graph is a data structure representing a computation:

Simple example:

def f(x):
    y = x ** 2        # Operation 1: square
    z = jnp.sin(y)    # Operation 2: sin
    return z

Computational graph:

x → [square] → y → [sin] → z

Each arrow carries a value (tensors/arrays), each box is an operation.

Slightly More Complex Example

def f(x):
    a = x + 1          # Op 1: add
    b = x * 2          # Op 2: multiply
    c = a * b          # Op 3: multiply
    return c

Graph:

    x
   / \
  /   \
[+1]  [*2]
  |    |
  a    b
   \  /
    \/
   [*]
    |
    c

Notice:

Real Physics Example: Gravitational Potential Energy

def gravitational_potential(positions, masses, G=6.67e-8):
    """
    U = -G * sum_{i<j} (m_i * m_j / r_ij)
    """
    n = len(masses)
    U_total = 0.0
    for i in range(n):
        for j in range(i+1, n):
            r_vec = positions[j] - positions[i]     # Op: vector subtraction
            r = jnp.linalg.norm(r_vec)               # Op: norm (sqrt of sum of squares)
            U_ij = -G * masses[i] * masses[j] / r    # Ops: multiply, divide
            U_total += U_ij                           # Op: accumulate
    return U_total

Simplified graph (for 2 particles):

positions[0], positions[1]
         |
      [subtract]
         |
       r_vec
         |
       [norm]
         |
         r
         |    masses[0], masses[1], G
         |         |         |        |
         └─────────[multiply]─────────┘
                   |
                [divide]
                   |
                 U_ij

JAX builds this graph automatically by tracing your Python code.

Why Computational Graphs Matter

1. Automatic Differentiation

Once you have a graph, you can compute derivatives automatically via chain rule:

Forward pass: Compute values from inputs to outputs Backward pass (autodiff): Compute gradients from outputs to inputs

def f(x):
    y = x ** 2       # y = x²
    z = jnp.sin(y)   # z = sin(y) = sin(x²)
    return z

# Graph:
# x → [square] → y → [sin] → z

# Gradients (chain rule):
# dz/dx = dz/dy * dy/dx
#       = cos(y) * 2x
#       = cos(x²) * 2x

JAX computes this automatically by traversing the graph backward.

More in Part 2! This is just the conceptual foundation.

2. JIT Compilation Optimization

Having the graph lets XLA compiler optimize:

Operation fusion:

# Your code:
y = x + 1
z = y * 2
w = z ** 2

# Naive execution: 3 separate kernels
# Optimized: XLA fuses into 1 kernel: w = ((x + 1) * 2) ** 2

Dead code elimination:

# Your code:
y = expensive_computation(x)
z = x ** 2
return z  # y is never used!

# XLA removes expensive_computation from graph

Memory optimization:

# XLA can reuse buffers when intermediate values aren't needed

3. Vectorization (vmap)

With the graph, vmap can automatically broadcast operations over batch dimensions.

Tracing: How JAX Builds the Graph

When you call a JIT-compiled function:

@jax.jit
def f(x):
    y = x ** 2
    z = jnp.sin(y)
    return z

result = f(3.0)

What happens:

  1. First call: Tracing phase

    • JAX calls f with a “tracer” (abstract value)

    • Tracer: ShapedArray(shape=(), dtype=float32)

    • Records operations: square, then sin

    • Builds graph: x → [square] → y → [sin] → z

    • Compiles graph to machine code

  2. Subsequent calls: Use cached compiled code

    • No Python execution!

    • Just run compiled graph with new values

This is why side effects break JIT:

@jax.jit
def f(x):
    print(f"x = {x}")  # Side effect
    return x ** 2

f(3.0)  # Prints: "x = Traced<ShapedArray(...)>" (tracer, not 3.0!)
f(5.0)  # Doesn't print! (cached compiled code)

The print happens during tracing (first call), not during execution (subsequent calls).

Pure Functions → Clean Graphs

Pure functions produce clean, predictable graphs:

# Pure function
def f(x, y):
    return x ** 2 + y ** 2

# Graph is simple:
# x, y → [square, square, add] → result

Impure functions produce unpredictable graphs:

# Impure function
counter = 0
def g(x):
    global counter
    counter += 1
    return x + counter

# Graph doesn't capture `counter` mutation!
# Compiled graph might compute x + 1 (from first trace)
# But counter keeps incrementing → mismatch

Visualizing Graphs with jax.make_jaxpr

JAX provides a tool to inspect computational graphs:

import jax

def f(x):
    y = x + 1
    z = y * 2
    return z

# See the graph (JAX intermediate representation)
print(jax.make_jaxpr(f)(3.0))

Output:

{ lambda ; a:f32[]. let
    b:f32[] = add a 1.0
    c:f32[] = mul b 2.0
  in (c,) }

This shows:

Useful for debugging! If your graph doesn’t look like you expect, you’ve found a bug.

Key Insights

  1. JAX sees graphs, not Python code

    • Your for-loops become scan operations in the graph

    • Your if-statements become cond nodes

    • Your arithmetic becomes primitive ops

  2. Graphs enable transformations

    • jit: Optimize and compile the graph

    • grad: Traverse graph backward computing derivatives

    • vmap: Add batch dimensions to graph nodes

  3. Pure functions → predictable graphs

    • No hidden dependencies

    • No mutations

    • Graph fully describes computation


1.5: What Are JAX Transformations?

Priority: 🔴 Essential

Transformations Are Functions That Transform Functions

This sounds abstract, but it’s powerful:

# A normal function
def f(x):
    return x ** 2

# A transformation that takes a function and returns a new function
fast_f = jax.jit(f)        # Returns a compiled version of f
grad_f = jax.grad(f)       # Returns a function that computes df/dx
batched_f = jax.vmap(f)    # Returns a vectorized version of f

You’re not just calling functions—you’re transforming them.

The Four Core Transformations

1. jit — Just-In-Time Compilation

What it does: Compile your function to machine code for speed

import jax
import jax.numpy as jnp

def slow_function(x):
    return jnp.sum(x ** 2)

fast_function = jax.jit(slow_function)

# Or use decorator:
@jax.jit
def fast_function(x):
    return jnp.sum(x ** 2)

Effect: Typically 10-100× faster (hardware-dependent)

When to use: Almost always! JIT compilation is JAX’s superpower.

Part 2 dives deep: How XLA works, when JIT overhead dominates, debugging tricks.

2. grad — Automatic Differentiation

What it does: Take derivatives automatically

def f(x):
    return x ** 2 + 3 * x + 5

# Derivative function
df_dx = jax.grad(f)

print(f(2.0))        # 15.0
print(df_dx(2.0))    # 7.0 = df/dx at x=2 = 2*2 + 3

Effect: Numerically exact derivatives to machine precision (not finite-difference approximations!)

When to use: Optimization, MCMC (HMC), physics-informed ML, anywhere you need gradients

Part 2 dives deep: Chain rule mechanics, Jacobians, higher-order derivatives.

3. vmap — Automatic Vectorization

What it does: Batch computations over arrays automatically

# Function for single input
def f(x):
    return x ** 2

# Vectorized version
batched_f = jax.vmap(f)

# Apply to batch
x_batch = jnp.array([1, 2, 3, 4, 5])
print(batched_f(x_batch))  # [1, 4, 9, 16, 25]

Effect: Automatic parallelization, GPU utilization

When to use: Ensemble simulations, batch processing, anytime you have independent computations

Part 2 dives deep: in_axes, out_axes, nested vmap, combining with grad.

4. pmap — Multi-Device Parallelization

What it does: Parallelize across GPUs/TPUs

# Run same function on multiple devices
parallel_f = jax.pmap(f)

# Data sharded across devices
x_sharded = jnp.array([...])  # Automatically distributed
result = parallel_f(x_sharded)  # Runs on all devices in parallel

Effect: Scale to multi-GPU/TPU systems

When to use: Large-scale training, ensemble simulations beyond single device

Part 2 preview: This is advanced—you’ll likely use it less often than jit/grad/vmap.

The Magic: Transformations Compose

This is where JAX becomes powerful:

def loss(params, data):
    predictions = model(params, data)
    return jnp.mean((predictions - targets) ** 2)

# Compose transformations!
fast_batched_gradients = jax.jit(jax.vmap(jax.grad(loss)))

# This is ONE transformation that:
# 1. Computes gradients (grad)
# 2. Over a batch of data (vmap)
# 3. Compiled for speed (jit)

Order matters sometimes:

jax.vmap(jax.grad(f))  # Batched gradients: gradient for each example
jax.grad(jax.vmap(f))  # Gradient of batched function: different!

But you can experiment! JAX transformations are functions—you can combine them any way that’s mathematically valid.

Contrast with NumPy

OperationNumPyJAX
Computeresult = f(x)Same: result = f(x)
Speed upWrite C extensionjax.jit(f)
DerivativesWrite df/dx by handjax.grad(f)
VectorizeManual broadcastingjax.vmap(f)
Multi-GPUMPI, CUDA kernelsjax.pmap(f)

NumPy requires doing optimization/parallelization/differentiation manually. JAX lets you describe what you want, then handles the how.

Example: Training Loop (Preview)

This won’t make complete sense yet—that’s fine! This shows how transformations combine in practice:

# Loss function
def loss_fn(params, data, targets):
    predictions = model(params, data)
    return jnp.mean((predictions - targets) ** 2)

# Gradient function (w.r.t. params)
grad_fn = jax.grad(loss_fn, argnums=0)

# Vectorized gradient (over batch)
batched_grad_fn = jax.vmap(grad_fn, in_axes=(None, 0, 0))

# JIT-compiled for speed
fast_batched_grad_fn = jax.jit(batched_grad_fn)

# Use in training loop
for epoch in range(num_epochs):
    for batch_data, batch_targets in dataloader:
        # Compute gradients for batch
        grads = fast_batched_grad_fn(params, batch_data, batch_targets)
        # Update parameters
        params = params - learning_rate * jnp.mean(grads, axis=0)

What’s happening:

Why This Is Different from TensorFlow/PyTorch

TensorFlow 1.x: Define static graph, then execute

PyTorch: Dynamic graphs, but tightly coupled with autodiff

JAX: Transformations are decoupled and composable

Key Takeaway

JAX transformations are function transforms:

They compose freely:

Part 2 teaches you:

For now, understand the concept: JAX gives you building blocks (transformations) that you compose to build fast, differentiable, parallel scientific code.


1.6: Why JAX Requires Functional Programming (Synthesis)

Priority: 🔴 Essential

Connecting All the Pieces

You’ve learned:

  1. OOP vs Functional: Different paradigms for organizing code

  2. Pure Functions: Same inputs → same outputs, no side effects

  3. Control Flow: Why Python if/for break, what lax provides

  4. Computational Graphs: How JAX “sees” your code

  5. Transformations: What jit, grad, vmap, pmap actually do

Now: Why do these all fit together?

The Constraint → Capability Table

ConstraintWhy It’s RequiredWhat It Enables
Pure functionsDeterministic behavior → predictable graphsJIT compilation, caching, reproducibility
No mutationClear data flow → graph structure preservedAutodiff, composability, parallelization
Explicit control flow (lax)Traceable at compile time → structure knownJIT optimization, compile-time loop unrolling
Explicit randomness (PRNG keys)Reproducible RNG state → pure samplingvmap over random processes, deterministic debugging
Functional updates (.at[])Immutable arrays → safe parallelizationvmap, pmap work correctly

Each constraint enables multiple capabilities.

Why Purity Enables JIT

JIT compilation requires:

If your function is impure:

Example:

cache = {}  # Global state

@jax.jit
def f(x):
    if x in cache:
        return cache[x]  # Hidden dependency!
    result = expensive_computation(x)
    cache[x] = result  # Side effect!
    return result

# First call (x=3): traces expensive_computation, caches result
# Second call (x=3): uses cached compiled code, but cache[3] exists!
# Result: graph doesn't match reality

Pure version:

# No global state, explicit dependencies
@jax.jit
def f(x, cache_dict):
    # ... functional caching logic ...
    return result, updated_cache

Why Immutability Enables Autodiff

Autodiff works by:

If you mutate data:

def f(x):
    y = x ** 2
    x = x + 1  # ❌ Mutation! x is now different
    z = x * y  # Which x? Original or mutated?
    return z

# Gradient computation becomes ambiguous
# Which path through the graph did we take?

Immutable version:

def f(x):
    y = x ** 2
    x_new = x + 1  # ✅ Create new variable
    z = x_new * y  # Clear which values are used
    return z

# Graph is unambiguous:
# x → [square] → y
# x → [+1] → x_new
# (x_new, y) → [multiply] → z

Why Explicit Control Flow Enables Compilation

Python if/for are dynamic:

lax.cond/scan are declarative:

Example:

# Python if (can't compile)
def f(x):
    if x > 0:
        return expensive_branch_A(x)
    else:
        return expensive_branch_B(x)

# lax.cond (compiles both branches)
def f_jax(x):
    return lax.cond(
        x > 0,
        expensive_branch_A,
        expensive_branch_B,
        x
    )

# At runtime: evaluates x > 0, selects which output to return
# Both branches compiled → fast execution

Why Functional Style Enables vmap

vmap requires:

If you mutate shared state:

accumulator = 0.0

def f(x):
    global accumulator
    accumulator += x  # ❌ Race condition!
    return accumulator

# vmap would execute f on multiple x values in parallel
# Race condition: undefined order of accumulator updates

Functional version:

def f(x, accumulator):
    new_accumulator = accumulator + x  # ✅ Pure transformation
    return new_accumulator, new_accumulator

# vmap works correctly:
batched_f = jax.vmap(f, in_axes=(0, None))
# Each batch element gets its own computation, no interference

The Profound Unity

All of JAX’s constraints serve one purpose: Enable automatic, composable transformations.

Pure Functions
    ↓
Clear Data Flow
    ↓
Traceable Computational Graphs
    ↓
JIT, Grad, Vmap, Pmap
    ↓
Fast, Differentiable, Parallel Scientific Code

You give up:

You gain:

The Glass-Box Connection

Remember from Module 1: Glass-box methodology means understanding WHY, not just HOW.

Black-box JAX user:

Glass-box JAX user (you, after Part 1):

By understanding the constraints → you understand the capabilities.

Practical Mental Checklist

When writing JAX code, ask:

  1. Is my function pure?

    • Same inputs → always same outputs?

    • No side effects (mutation, I/O, global state)?

  2. Is my data flow explicit?

    • All inputs in function parameters?

    • All outputs in return values?

    • No hidden dependencies?

  3. Is my control flow traceable?

    • Loops with static ranges, or using lax.scan?

    • Conditionals using jnp.where or lax.cond?

  4. Am I creating new data, not mutating?

    • Using x = x + 1, not x += 1?

    • Using .at[].set(), not [i] = value?

If yes to all → JAX will work smoothly. If no → expect errors.

Looking Ahead to Part 2

Part 1 built your conceptual foundation. You understand:

Part 2 teaches technical mastery:

You’re ready. The conceptual understanding you’ve built makes Part 2’s technical details make sense.


Summary: What You’ve Learned

Priority: 🔴 Essential

TL;DR: JAX’s functional constraints (purity, immutability, explicit control) aren’t arbitrary—they’re precisely what enable automatic transformations (JIT, grad, vmap). Understanding WHY these constraints exist is the key to using JAX effectively.

Part 1 built the conceptual foundation for JAX:

Programming paradigms: OOP models objects with state; functional models transformations of immutable data

Pure functions: Same inputs → same outputs, no side effects. Required for JIT, autodiff, vmap.

Control flow: Python if/for require values at compile time (can’t trace). Use lax.cond, lax.scan, lax.fori_loop.

Computational graphs: JAX sees your code as DAG of operations. Enables transformations.

Transformations: jit (compile), grad (differentiate), vmap (vectorize), pmap (parallelize). Compose freely.

Constraints → Capabilities: Functional constraints aren’t arbitrary—they enable automatic, composable transformations.

You now understand WHY JAX works the way it does.

Next: Part 2: Core Transformations — Learn the technical details, practice using transformations, understand autodiff mathematics, build real JAX code.

Remember: Functional programming will feel awkward for a few weeks. Then it will click. By Project 5, you’ll prefer it. Trust the process.


Understanding Checklist

Before proceeding to Part 2, ensure you can:

If you answered “yes” to all → Ready for Part 2: Core Transformations


Continue to Part 2: Core Transformations — From Concepts to Code