Theory NotebookMath for LLMs

Derivatives and Differentiation

Calculus Fundamentals / Derivatives and Differentiation

Run notebook
Private notes
0/8000

Notes stay private to your browser until account sync is configured.

Theory Notebook

Theory Notebook

Converted from theory.ipynb for web reading.

Derivatives and Differentiation

"The derivative is the instantaneous rate of change — and backpropagation is just the chain rule applied to a computation graph."

Interactive theory notebook covering: derivative definition, differentiation rules, chain rule, activation function derivatives, numerical differentiation, and optimization foundations.

Companion: notes.md | exercises.ipynb

Code cell 2

import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl

try:
    import seaborn as sns
    sns.set_theme(style="whitegrid", palette="colorblind")
    HAS_SNS = True
except ImportError:
    plt.style.use("seaborn-v0_8-whitegrid")
    HAS_SNS = False

mpl.rcParams.update({
    "figure.figsize":    (10, 6),
    "figure.dpi":         120,
    "font.size":           13,
    "axes.titlesize":      15,
    "axes.labelsize":      13,
    "xtick.labelsize":     11,
    "ytick.labelsize":     11,
    "legend.fontsize":     11,
    "legend.framealpha":   0.85,
    "lines.linewidth":      2.0,
    "axes.spines.top":     False,
    "axes.spines.right":   False,
    "savefig.bbox":       "tight",
    "savefig.dpi":         150,
})
np.random.seed(42)
print("Plot setup complete.")

Code cell 3

import numpy as np
import numpy.linalg as la
from scipy import integrate, special, stats
from math import factorial
import matplotlib.patches as patches

COLORS = {
    "primary": "#0077BB",
    "secondary": "#EE7733",
    "tertiary": "#009988",
    "error": "#CC3311",
    "neutral": "#555555",
    "highlight": "#EE3377",
}
HAS_MPL = True
np.set_printoptions(precision=8, suppress=True)
np.random.seed(42)

def header(title):
    print("\n" + "=" * len(title))
    print(title)
    print("=" * len(title))

def check_true(name, cond):
    ok = bool(cond)
    print(f"{'PASS' if ok else 'FAIL'} - {name}")
    return ok

def check_close(name, got, expected, tol=1e-8):
    ok = np.allclose(got, expected, atol=tol, rtol=tol)
    print(f"{'PASS' if ok else 'FAIL'} - {name}: got {got}, expected {expected}")
    return ok



def centered_diff(f, x, h=1e-6):
    return (f(x + h) - f(x - h)) / (2 * h)

def forward_diff(f, x, h=1e-6):
    return (f(x + h) - f(x)) / h

def backward_diff(f, x, h=1e-6):
    return (f(x) - f(x - h)) / h



def grad_check(f, x, analytic_grad, h=1e-6):
    x = np.asarray(x, dtype=float)
    analytic_grad = np.asarray(analytic_grad, dtype=float)
    numeric_grad = np.zeros_like(x, dtype=float)
    for idx in np.ndindex(x.shape):
        x_plus = x.copy(); x_minus = x.copy()
        x_plus[idx] += h; x_minus[idx] -= h
        numeric_grad[idx] = (f(x_plus) - f(x_minus)) / (2 * h)
    denom = la.norm(analytic_grad) + la.norm(numeric_grad) + 1e-12
    return la.norm(analytic_grad - numeric_grad) / denom



def check(name, got, expected, tol=1e-8):
    return check_close(name, got, expected, tol=tol)

print("Chapter helper setup complete.")

1. Intuition — The Derivative as Instantaneous Rate of Change

The derivative measures how fast a function changes at a point. We visualize the secant-to-tangent limit and explore what the derivative means geometrically.

Code cell 5

# === 1.1 Secant Lines Converging to Tangent ===

def f(x):
    return x**2

def f_prime(x):
    return 2*x

a = 1.0  # Point of tangency
h_values = [1.0, 0.5, 0.2, 0.05]

x = np.linspace(-0.5, 2.5, 300)

if HAS_MPL:
    colors = plt.cm.plasma(np.linspace(0.2, 0.9, len(h_values)))
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    ax = axes[0]
    ax.plot(x, f(x), 'k-', lw=2, label='$f(x) = x^2$')
    # Secant lines
    for i, h in enumerate(h_values):
        slope = (f(a+h) - f(a)) / h
        x_line = np.linspace(a-0.3, a+h+0.3, 50)
        y_line = f(a) + slope*(x_line - a)
        ax.plot(x_line, y_line, '--', color=colors[i], lw=1.5,
                label=f'h={h:.2f}, slope={slope:.2f}')
    # Tangent
    x_tang = np.linspace(a-0.8, a+0.8, 50)
    ax.plot(x_tang, f(a) + f_prime(a)*(x_tang - a), 'r-', lw=2.5,
            label=f"Tangent: slope={f_prime(a):.1f}")
    ax.plot(a, f(a), 'ro', ms=8)
    ax.set_xlim(-0.2, 2.2); ax.set_ylim(-0.2, 4.5)
    ax.set_xlabel('x'); ax.set_ylabel('f(x)')
    ax.set_title('Secant Lines → Tangent Line')
    ax.legend(fontsize=9)

    # Right panel: slope vs h
    ax2 = axes[1]
    h_fine = np.logspace(-4, 0, 200)
    slopes = [(f(a+h) - f(a)) / h for h in h_fine]
    ax2.semilogx(h_fine, slopes, 'b-', lw=2, label='Secant slope $(f(1+h)-f(1))/h$')
    ax2.axhline(f_prime(a), color='r', ls='--', lw=2, label=f"True $f'(1)$ = {f_prime(a)}")
    ax2.set_xlabel('Step size h'); ax2.set_ylabel('Slope')
    ax2.set_title('Convergence of Secant to Tangent')
    ax2.legend()

    plt.tight_layout()
    plt.savefig('/tmp/secant_tangent.png', dpi=100, bbox_inches='tight')
    plt.show()
    print('Figure saved.')

# Verify derivative
h = 1e-7
numerical = (f(1+h) - f(1)) / h
analytic = f_prime(1)
print(f'Numerical f\'(1): {numerical:.8f}')
print(f'Analytic  f\'(1): {analytic:.8f}')
print(f'PASS - error < 1e-6: {abs(numerical - analytic) < 1e-6}')

2. Formal Definition — Derivative as a Limit

f(a)=limh0f(a+h)f(a)hf'(a) = \lim_{h \to 0} \frac{f(a+h) - f(a)}{h}

We compute this limit explicitly for several functions and verify symbolically.

Code cell 7

# === 2.1 Derivative from Definition — Worked Examples ===

# Example 1: f(x) = x^2, f'(x) = 2x
def limit_x2(x, h):
    return ((x+h)**2 - x**2) / h

# Example 2: f(x) = sqrt(x), f'(x) = 1/(2*sqrt(x))
def limit_sqrt(x, h):
    return (np.sqrt(x+h) - np.sqrt(x)) / h

# Example 3: f(x) = e^x, f'(x) = e^x
def limit_exp(x, h):
    return (np.exp(x+h) - np.exp(x)) / h

x0 = 2.0
h_vals = [0.1, 0.01, 0.001, 1e-6]

print('=== Convergence of Difference Quotients ===')
print(f'\nf(x) = x^2 at x={x0}  (true f\'={2*x0})')
for h in h_vals:
    print(f'  h={h:.0e}: {limit_x2(x0, h):.8f}')

print(f'\nf(x) = sqrt(x) at x={x0}  (true f\'={1/(2*np.sqrt(x0)):.8f})')
for h in h_vals:
    print(f'  h={h:.0e}: {limit_sqrt(x0, h):.8f}')

print(f'\nf(x) = e^x at x={x0}  (true f\'={np.exp(x0):.8f})')
for h in h_vals:
    print(f'  h={h:.0e}: {limit_exp(x0, h):.8f}')

Code cell 8

# === 2.2 Differentiable vs Continuous — ReLU and |x| ===

x = np.linspace(-2, 2, 400)

relu = np.maximum(0, x)
abs_x = np.abs(x)

# Difference quotients at x=0
h_vals = [0.5, 0.1, 0.01, 0.001]
print('=== Left and Right Derivatives at x=0 ===')
print('\nReLU:')
for h in h_vals:
    left = (np.maximum(0, -h) - 0) / (-h)
    right = (np.maximum(0, h) - 0) / h
    print(f'  h={h:.3f}: left={left:.3f}, right={right:.3f}')
print('  → right limit = 1, left limit = 0 → NOT differentiable (subgradient ∈ [0,1])')

print('\n|x|:')
for h in h_vals:
    left = (abs(-h) - 0) / (-h)
    right = (abs(h) - 0) / h
    print(f'  h={h:.3f}: left={left:.3f}, right={right:.3f}')
print('  → right limit = 1, left limit = -1 → NOT differentiable (corner)')

if HAS_MPL:
    fig, axes = plt.subplots(1, 2, figsize=(12, 4))
    for ax, func, name in [(axes[0], relu, 'ReLU(x)'), (axes[1], abs_x, '|x|')]:
        ax.plot(x, func, 'b-', lw=2, label=name)
        ax.axvline(0, color='r', ls='--', alpha=0.5, label='x=0 (non-diff.)')
        ax.set_xlabel('x'); ax.set_ylabel('f(x)')
        ax.set_title(f'{name} — continuous but not differentiable at 0')
        ax.legend()
    plt.tight_layout(); plt.show()

3. Basic Differentiation Rules

The power, product, quotient, and sum rules let us differentiate any algebraic expression without computing limits. We verify each rule numerically.

Code cell 10

# === 3.1-3.5 Differentiation Rules — Numerical Verification ===

def centered_diff(f, x, h=1e-5):
    return (f(x+h) - f(x-h)) / (2*h)

def check(name, analytic_fn, numerical_fn, x):
    a = analytic_fn(x)
    n = numerical_fn(x)
    ok = abs(a - n) < 1e-6
    print(f"{'PASS' if ok else 'FAIL'} {name}: analytic={a:.8f}, numerical={n:.8f}")

x = 1.5
print('=== Derivative Rules — Verification at x=1.5 ===')

# Power rule: (x^5)' = 5x^4
check('Power rule x^5', lambda x: 5*x**4, lambda x: centered_diff(lambda t: t**5, x), x)

# Product rule: (x^2 * sin(x))' = 2x*sin(x) + x^2*cos(x)
check('Product rule x^2*sin(x)', 
      lambda x: 2*x*np.sin(x) + x**2*np.cos(x),
      lambda x: centered_diff(lambda t: t**2*np.sin(t), x), x)

# Quotient rule: (x^2/(x+1))' = (2x(x+1) - x^2)/(x+1)^2 = (x^2+2x)/(x+1)^2
check('Quotient rule x^2/(x+1)',
      lambda x: (x**2 + 2*x) / (x+1)**2,
      lambda x: centered_diff(lambda t: t**2/(t+1), x), x)

# Chain rule: sin(x^3)' = cos(x^3)*3x^2
check('Chain rule sin(x^3)',
      lambda x: np.cos(x**3)*3*x**2,
      lambda x: centered_diff(lambda t: np.sin(t**3), x), x)

# Logarithm: (ln(x))' = 1/x
check('Logarithm (ln x)',
      lambda x: 1/x,
      lambda x: centered_diff(np.log, x), x)

# Composite: e^(-x^2/2) - Gaussian kernel
check('Gaussian e^(-x^2/2)',
      lambda x: -x*np.exp(-x**2/2),
      lambda x: centered_diff(lambda t: np.exp(-t**2/2), x), x)

4. The Chain Rule

(fg)(x)=f(g(x))g(x)(f \circ g)'(x) = f'(g(x)) \cdot g'(x)

The chain rule is the mathematical foundation of backpropagation. Every node in a computation graph applies this rule to propagate gradients.

Code cell 12

# === 4.1-4.3 Chain Rule — Composition Analysis ===

def centered_diff(f, x, h=1e-6):
    return (f(x+h) - f(x-h)) / (2*h)

x_vals = np.linspace(-2, 2, 200)
x = 1.0

# Example 1: y = e^(-x^2/2) — Gaussian
f1 = lambda t: np.exp(-t**2/2)
f1_prime_analytic = lambda t: -t * np.exp(-t**2/2)
err1 = abs(centered_diff(f1, x) - f1_prime_analytic(x))
print(f'Gaussian derivative error: {err1:.2e}')

# Example 2: y = sin(ln(x)) for x>0
f2 = lambda t: np.sin(np.log(t))
f2_prime_analytic = lambda t: np.cos(np.log(t)) / t
err2 = abs(centered_diff(f2, 2.0) - f2_prime_analytic(2.0))
print(f'sin(ln(x)) derivative error: {err2:.2e}')

# Example 3: y = (1+x^2)^10
f3 = lambda t: (1+t**2)**10
f3_prime_analytic = lambda t: 20*t*(1+t**2)**9
err3 = abs(centered_diff(f3, x) - f3_prime_analytic(x))
print(f'(1+x^2)^10 derivative error: {err3:.2e}')

print('\nAll chain rule verifications PASS' if max(err1,err2,err3) < 1e-5 else 'CHECK FAILED')

# Scalar backprop demo
print('\n=== Scalar Backpropagation Demo ===')
print('Network: L = (sigma(w*x+b) - y)^2 / 2')
w, b, x_in, y = 2.0, -1.0, 1.5, 0.8

# Forward pass
z = w*x_in + b
sigma_z = 1 / (1 + np.exp(-z))
L = 0.5 * (sigma_z - y)**2

# Backward pass
dL_da = sigma_z - y
da_dz = sigma_z * (1 - sigma_z)
dL_dz = dL_da * da_dz
dL_dw = dL_dz * x_in
dL_db = dL_dz * 1

# Numerical check
num_dw = (0.5*(1/(1+np.exp(-(w+1e-5)*x_in+b))-y)**2 - 0.5*(1/(1+np.exp(-(w-1e-5)*x_in+b))-y)**2) / 2e-5
print(f'dL/dw — analytic: {dL_dw:.8f}, numerical: {num_dw:.8f}')
print(f'PASS: {abs(dL_dw - num_dw) < 1e-5}')

5. Implicit Differentiation and Linear Approximation

When yy is defined implicitly by F(x,y)=0F(x,y)=0, differentiate both sides w.r.t. xx treating y=y(x)y=y(x). Linear approximation f(x)f(a)+f(a)(xa)f(x)\approx f(a)+f'(a)(x-a) is the derivative's most direct application.

Code cell 14

# === 5.1-5.3 Implicit Differentiation and Linear Approximation ===

import numpy as np

# --- Implicit: circle x^2 + y^2 = r^2 ---
# dy/dx = -x/y
r = 3.0
theta = np.linspace(0, 2*np.pi, 400)
x_circ = r * np.cos(theta)
y_circ = r * np.sin(theta)

# At point (x0, y0) on upper semicircle
x0 = 2.0
y0 = np.sqrt(r**2 - x0**2)
slope_implicit = -x0 / y0

# Verify numerically: y = sqrt(r^2 - x^2), dy/dx = -x/sqrt(r^2-x^2)
slope_numerical = (-x0 / np.sqrt(r**2 - x0**2))
print(f'Circle implicit slope at ({x0:.1f}, {y0:.3f}): {slope_implicit:.6f}')
print(f'Numerical check: {slope_numerical:.6f}')
print(f'PASS: {abs(slope_implicit - slope_numerical) < 1e-10}')

# --- Linear approximation demo ---
print('\n=== Linear Approximation: sin(x) near x=pi/4 ===')
a = np.pi / 4
f_a = np.sin(a)
f_prime_a = np.cos(a)

x_test_vals = [a + dx for dx in [-0.3, -0.1, 0.1, 0.3]]
print(f'Linearization: f(x) ≈ {f_a:.4f} + {f_prime_a:.4f}*(x - {a:.4f})')
print(f'{"x":>8} {"True f(x)":>12} {"Linear approx":>14} {"Error":>10}')
for x in x_test_vals:
    true_val = np.sin(x)
    linear_val = f_a + f_prime_a*(x - a)
    error = abs(true_val - linear_val)
    print(f'{x:8.4f} {true_val:12.6f} {linear_val:14.6f} {error:10.2e}')

# Cross-entropy gradient via related rates
print('\n=== Cross-Entropy Gradient via Chain Rule ===')
z = 1.5
sigma_z = 1/(1+np.exp(-z))
# L = -log(sigma(z)), dL/dz = sigma(z) - 1
dL_dz_analytic = sigma_z - 1

h = 1e-6
L_plus = -np.log(1/(1+np.exp(-(z+h))))
L_minus = -np.log(1/(1+np.exp(-(z-h))))
dL_dz_numerical = (L_plus - L_minus) / (2*h)
print(f'dL/dz = sigma(z)-1: analytic={dL_dz_analytic:.8f}, numerical={dL_dz_numerical:.8f}')
print(f'PASS: {abs(dL_dz_analytic - dL_dz_numerical) < 1e-7}')

6. Higher-Order Derivatives and Concavity

f(x)>0f''(x) > 0 means concave up; f(x)<0f''(x) < 0 means concave down. The second derivative determines local curvature of the loss surface.

Code cell 16

# === 6.1-6.4 Higher-Order Derivatives ===

try:
    import matplotlib.pyplot as plt
    HAS_MPL = True
except ImportError:
    HAS_MPL = False

x = np.linspace(-3, 3, 500)

# f = x^4 - 4x^2 (W-shape — two minima)
f_vals = x**4 - 4*x**2
fp_vals = 4*x**3 - 8*x
fpp_vals = 12*x**2 - 8

# Critical points: f' = 0 => 4x^3 - 8x = 4x(x^2-2) = 0 => x in {0, ±sqrt(2)}
crit = [0, np.sqrt(2), -np.sqrt(2)]
print('=== Critical Points of f(x) = x^4 - 4x^2 ===')
for c in crit:
    fpc = 4*c**3 - 8*c
    fppc = 12*c**2 - 8
    kind = 'local min' if fppc > 0 else ('local max' if fppc < 0 else 'inconclusive')
    print(f"  x={c:+.4f}: f={c**4-4*c**2:.4f}, f_prime={fpc:.4f}, f_double_prime={fppc:.4f} -> {kind}")

# Inflection points: f'' = 0 => 12x^2 = 8 => x = ±sqrt(2/3)
infl = np.sqrt(2/3)
print(f'\nInflection points at x = ±{infl:.4f}')

if HAS_MPL:
    fig, axes = plt.subplots(1, 3, figsize=(15, 5))
    for ax, vals, name, color in zip(axes,
        [f_vals, fp_vals, fpp_vals],
        ['f(x) = x⁴ - 4x²', "f'(x)", "f''(x)"],
        ['blue', 'green', 'red']):
        ax.plot(x, vals, color=color, lw=2, label=name)
        ax.axhline(0, color='k', lw=0.8)
        ax.set_xlim(-3, 3); ax.set_ylim(-5, 10)
        ax.set_xlabel('x'); ax.set_title(name)
        ax.legend()
    plt.suptitle('Function, First, and Second Derivative', fontsize=13)
    plt.tight_layout(); plt.show()

7. Extrema, Critical Points, and the Mean Value Theorem

The MVT guarantees a point where the instantaneous rate equals the average rate. This underlies Lipschitz bounds and gradient descent convergence theory.

Code cell 18

# === 7.1-7.4 Extrema and MVT ===

# --- Global extrema on closed interval ---
def f(x):
    return x**3 - 3*x

def fp(x):
    return 3*x**2 - 3

a, b = -2.0, 2.5

# Critical points in (a,b): f'(x) = 0 => x = ±1
crit = [-1.0, 1.0]
candidates = [a] + [c for c in crit if a < c < b] + [b]

print('=== Global Extrema of f(x) = x^3 - 3x on [-2, 2.5] ===')
print(f'{"Point":>8} {"f(x)":>10} {"Type":>15}')
f_vals = [(x, f(x)) for x in candidates]
min_x = min(f_vals, key=lambda p: p[1])
max_x = max(f_vals, key=lambda p: p[1])
for pt, val in f_vals:
    kind = 'Global MIN' if pt == min_x[0] else ('Global MAX' if pt == max_x[0] else 'Candidate')
    print(f'{pt:8.3f} {val:10.4f} {kind:>15}')

# --- MVT illustration ---
print('\n=== Mean Value Theorem ===')
x_mvt = np.linspace(a, b, 1000)
avg_slope = (f(b) - f(a)) / (b - a)
print(f'Average slope [f({b})-f({a})] / ({b}-{a}) = {avg_slope:.4f}')

# Find c where f'(c) = avg_slope: 3c^2 - 3 = avg_slope => c = sqrt((avg_slope+3)/3)
c_mvt = np.sqrt((avg_slope + 3) / 3)
print(f'MVT guarantees c in ({a},{b}) where f\'(c) = {avg_slope:.4f}')
print(f'Found c = {c_mvt:.4f}, f\'({c_mvt:.4f}) = {fp(c_mvt):.4f}')
print(f'PASS — MVT c in interval: {a < c_mvt < b}')

8. Activation Function Derivatives

Activation functions and their derivatives control gradient flow in neural networks. The vanishing gradient problem arises from small f|f'| values compounding through layers.

Code cell 20

# === 8.1-8.5 Activation Derivatives ===

try:
    import matplotlib.pyplot as plt
    HAS_MPL = True
except:
    HAS_MPL = False

x = np.linspace(-5, 5, 500)

# Sigmoid
def sigmoid(x): return 1 / (1 + np.exp(-np.clip(x, -500, 500)))
def sigmoid_prime(x): s = sigmoid(x); return s * (1 - s)

# Tanh
def tanh_prime(x): return 1 - np.tanh(x)**2

# ReLU
def relu(x): return np.maximum(0, x)
def relu_prime(x): return np.where(np.asarray(x) > 0, 1.0, 0.0)

# GELU
from scipy.special import erf
def gelu(x): return x * 0.5 * (1 + erf(x / np.sqrt(2)))
def gelu_prime(x):
    Phi = 0.5 * (1 + erf(x / np.sqrt(2)))
    phi = np.exp(-x**2/2) / np.sqrt(2*np.pi)
    return Phi + x * phi

# Numerical verification of derivatives at x=1.5
x0 = 1.5
h = 1e-6
def num_deriv(f, x): return (f(x+h) - f(x-h)) / (2*h)

print('=== Activation Derivative Verification at x=1.5 ===')
tests = [
    ('Sigmoid', sigmoid, sigmoid_prime),
    ('Tanh', np.tanh, tanh_prime),
    ('ReLU', relu, relu_prime),
    ('GELU', gelu, gelu_prime),
]
for name, fn, fn_prime in tests:
    a = fn_prime(x0)
    n = num_deriv(fn, x0)
    ok = abs(a - n) < 1e-5
    print(f"  {'PASS' if ok else 'FAIL'} {name}: analytic={a:.6f}, numerical={n:.6f}")

# Vanishing gradient analysis
print('\n=== Vanishing Gradient — Sigmoid Product Through L Layers ===')
z_typical = 2.0  # typical saturated input
sig_prime_val = sigmoid_prime(z_typical)
print(f'sigma\'({z_typical}) = {sig_prime_val:.6f}')
for L in [5, 10, 20, 50]:
    product = sig_prime_val**L
    print(f'  {L} layers: (sigma\')^{L} = {product:.2e}')

Code cell 21

# === 8.5 Activation Derivatives — Visualization ===

try:
    import matplotlib.pyplot as plt
    from scipy.special import erf
    HAS_MPL = True
except:
    HAS_MPL = False

import numpy as np

def sigmoid(x): return 1 / (1 + np.exp(-np.clip(x, -500, 500)))
def sigmoid_prime(x): s = sigmoid(x); return s * (1 - s)
def relu_prime(x): return np.where(np.asarray(x) > 0, 1.0, 0.0)
def gelu(x): return x * 0.5 * (1 + erf(x / np.sqrt(2)))
def gelu_prime(x):
    Phi = 0.5 * (1 + erf(x / np.sqrt(2)))
    phi = np.exp(-x**2/2) / np.sqrt(2*np.pi)
    return Phi + x * phi

x = np.linspace(-4, 4, 400)

if HAS_MPL:
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    # Left: activation functions
    ax = axes[0]
    ax.plot(x, sigmoid(x), label='Sigmoid', lw=2)
    ax.plot(x, np.tanh(x), label='Tanh', lw=2)
    ax.plot(x, np.maximum(0, x), label='ReLU', lw=2)
    ax.plot(x, gelu(x), label='GELU', lw=2, ls='--')
    ax.set_xlabel('x'); ax.set_ylabel('f(x)')
    ax.set_title('Activation Functions')
    ax.legend(); ax.set_ylim(-1.5, 4)

    # Right: derivatives
    ax2 = axes[1]
    ax2.plot(x, sigmoid_prime(x), label="Sigmoid'", lw=2)
    ax2.plot(x, 1 - np.tanh(x)**2, label="Tanh'", lw=2)
    ax2.plot(x, relu_prime(x), label="ReLU'", lw=2)
    ax2.plot(x, gelu_prime(x), label="GELU'", lw=2, ls='--')
    ax2.axhline(0.25, color='gray', ls=':', lw=1, label='1/4 (sigmoid max)')
    ax2.set_xlabel('x'); ax2.set_ylabel("f'(x)")
    ax2.set_title('Activation Derivatives')
    ax2.legend(); ax2.set_ylim(-0.2, 1.2)

    plt.tight_layout(); plt.show()
    print('Activation derivatives plotted.')
print('Peak gradient — Sigmoid:', f"{max(sigmoid_prime(x)):.4f} at x=0")
print('Peak gradient — Tanh:   ', f"{max(1-np.tanh(x)**2):.4f} at x=0")
print('Constant grad — ReLU:  1.0 for x>0 (no saturation)')

9. Numerical Differentiation

Finite differences approximate derivatives numerically. Two errors compete: truncation (decreases with hh) and round-off (increases as h0h\to 0). The centered difference O(h2)O(h^2) is strongly preferred over the forward difference O(h)O(h).

Code cell 23

# === 9.1-9.2 Finite Differences — Error Analysis ===

import numpy as np

try:
    import matplotlib.pyplot as plt
    HAS_MPL = True
except:
    HAS_MPL = False

def f(x): return np.sin(x)
def f_prime(x): return np.cos(x)

x0 = 1.0
true_deriv = f_prime(x0)

h_vals = np.logspace(-16, 0, 160)

forward_errors = []
centered_errors = []

for h in h_vals:
    fwd = (f(x0+h) - f(x0)) / h
    ctr = (f(x0+h) - f(x0-h)) / (2*h)
    forward_errors.append(abs(fwd - true_deriv))
    centered_errors.append(abs(ctr - true_deriv))

if HAS_MPL:
    fig, ax = plt.subplots(figsize=(10, 6))
    ax.loglog(h_vals, forward_errors, 'b-', lw=2, label='Forward diff O(h)')
    ax.loglog(h_vals, centered_errors, 'r-', lw=2, label='Centered diff O(h²)')
    # Reference slopes
    h_ref = np.array([1e-8, 1e-4])
    ax.loglog(h_ref, h_ref, 'b--', alpha=0.5, label='O(h) slope')
    ax.loglog(h_ref, h_ref**2 * 1e4, 'r--', alpha=0.5, label='O(h²) slope')
    ax.set_xlabel('Step size h')
    ax.set_ylabel('|Error|')
    ax.set_title('Finite Difference Error vs Step Size (sin(x) at x=1)')
    ax.legend()
    plt.tight_layout(); plt.show()

# Find optimal h
opt_fwd_idx = np.argmin(forward_errors)
opt_ctr_idx = np.argmin(centered_errors)
print(f'Optimal h for forward diff: {h_vals[opt_fwd_idx]:.2e} (error: {forward_errors[opt_fwd_idx]:.2e})')
print(f'Optimal h for centered diff: {h_vals[opt_ctr_idx]:.2e} (error: {centered_errors[opt_ctr_idx]:.2e})')
print(f'Theoretical optimal centered: ~6e-6 (eps_mach^(1/3))')

Code cell 24

# === 9.3 Gradient Checking — Verifying Analytical Gradients ===

import numpy as np

def grad_check_scalar(f, x, analytic_grad, h=1e-5):
    """Check gradient of scalar-to-scalar function f."""
    numerical = (f(x+h) - f(x-h)) / (2*h)
    rel_error = abs(analytic_grad - numerical) / (abs(analytic_grad) + abs(numerical) + 1e-10)
    return rel_error, numerical

def grad_check_vector(f, x, analytic_grad, h=1e-5):
    """Check gradient of vector-to-scalar function f."""
    n = len(x)
    numerical_grad = np.zeros(n)
    for i in range(n):
        x_plus = x.copy(); x_plus[i] += h
        x_minus = x.copy(); x_minus[i] -= h
        numerical_grad[i] = (f(x_plus) - f(x_minus)) / (2*h)
    rel_error = np.linalg.norm(analytic_grad - numerical_grad) / \
                (np.linalg.norm(analytic_grad) + np.linalg.norm(numerical_grad) + 1e-10)
    return rel_error, numerical_grad

print('=== Gradient Checking Examples ===')

# Example 1: f(x) = x*ln(x+e^(-x))
def f1(x): return x * np.log(x + np.exp(-x))
def f1_prime(x): return np.log(x + np.exp(-x)) + x * (1 - np.exp(-x)) / (x + np.exp(-x))

x_test = 1.5
err1, num1 = grad_check_scalar(f1, x_test, f1_prime(x_test))
print(f'f(x) = x*ln(x+e^-x): analytic={f1_prime(x_test):.6f}, numerical={num1:.6f}')
print(f'  Relative error: {err1:.2e}{"PASS" if err1 < 1e-5 else "FAIL"}')

# Example 2: L2 loss gradient
def loss(w):
    x = np.array([1.0, 2.0, -1.0])
    y = 1.5
    return 0.5 * (np.dot(w, x) - y)**2

def loss_grad(w):
    x = np.array([1.0, 2.0, -1.0])
    y = 1.5
    return (np.dot(w, x) - y) * x

w = np.array([0.5, -0.3, 1.2])
err2, num_grad = grad_check_vector(loss, w, loss_grad(w))
print(f'\nL2 loss gradient:')
print(f'  Analytic: {loss_grad(w)}')
print(f'  Numerical: {num_grad}')
print(f'  Relative error: {err2:.2e}{"PASS" if err2 < 1e-5 else "FAIL"}')

10. Key Proofs — Interactive Verification

We verify the main derivative proofs computationally, checking that the theoretical formulas match numerical estimates to machine precision.

Code cell 26

# === 10. Proof Verification ===

import numpy as np

def centered(f, x, h=1e-7):
    return (f(x+h) - f(x-h)) / (2*h)

print('=== Verifying All Main Derivative Rules ===')

tests = [
    # (name, analytic_fn, numerical_fn, x)
    ('Power x^7', lambda x: 7*x**6, lambda x: centered(lambda t: t**7, x), 1.5),
    ('sin(x)', np.cos, lambda x: centered(np.sin, x), 1.2),
    ('cos(x)', lambda x: -np.sin(x), lambda x: centered(np.cos, x), 0.7),
    ('e^x', np.exp, lambda x: centered(np.exp, x), 2.0),
    ('ln(x)', lambda x: 1/x, lambda x: centered(np.log, x), 3.0),
    ('arctan(x)', lambda x: 1/(1+x**2), lambda x: centered(np.arctan, x), 1.0),
    ('arcsin(x)', lambda x: 1/np.sqrt(1-x**2), lambda x: centered(np.arcsin, x), 0.5),
    ('x^x (log diff)', lambda x: x**x*(np.log(x)+1), lambda x: centered(lambda t: t**t, x), 2.0),
    ('x^2*e^x (product)', lambda x: x**2*np.exp(x) + 2*x*np.exp(x), 
                          lambda x: centered(lambda t: t**2*np.exp(t), x), 1.5),
    ('sin(e^x) (chain)', lambda x: np.cos(np.exp(x))*np.exp(x),
                          lambda x: centered(lambda t: np.sin(np.exp(t)), x), 0.5),
    ('Softplus derivative=sigma', lambda x: 1/(1+np.exp(-x)),
                                   lambda x: centered(lambda t: np.log(1+np.exp(t)), x), 1.0),
]

all_pass = True
for name, analytic, numerical, x in tests:
    a = analytic(x); n = numerical(x)
    err = abs(a - n) / (abs(a) + 1e-10)
    ok = err < 1e-5
    all_pass = all_pass and ok
    print(f"  {'PASS' if ok else 'FAIL'} {name}: err={err:.2e}")

print(f'\nAll tests: {"PASS" if all_pass else "SOME FAILED"}')

11. Application — Adam Optimizer Analysis

The Adam optimizer uses first and second derivative moments to adapt learning rates. We simulate Adam on a quadratic and visualize convergence compared to vanilla SGD.

Code cell 28

# === 11. Adam Optimizer — Single Variable Simulation ===

import numpy as np

try:
    import matplotlib.pyplot as plt
    HAS_MPL = True
except:
    HAS_MPL = False

# Loss: f(theta) = (theta - 3)^2 + 0.1*sin(5*theta)
def loss(theta): return (theta - 3)**2 + 0.1*np.sin(5*theta)
def grad(theta): return 2*(theta - 3) + 0.5*np.cos(5*theta)

def run_sgd(theta0, lr=0.1, steps=200):
    theta = theta0; trajectory = [theta0]
    for _ in range(steps):
        theta -= lr * grad(theta)
        trajectory.append(theta)
    return np.array(trajectory)

def run_adam(theta0, lr=0.1, b1=0.9, b2=0.999, eps=1e-8, steps=200):
    theta = theta0; m = 0.0; v = 0.0
    trajectory = [theta0]
    for t in range(1, steps+1):
        g = grad(theta)
        m = b1*m + (1-b1)*g
        v = b2*v + (1-b2)*g**2
        m_hat = m / (1 - b1**t)
        v_hat = v / (1 - b2**t)
        theta -= lr * m_hat / (np.sqrt(v_hat) + eps)
        trajectory.append(theta)
    return np.array(trajectory)

theta0 = -2.0
sgd_traj = run_sgd(theta0, lr=0.1)
adam_traj = run_adam(theta0, lr=0.5)

print('=== Optimizer Comparison ===')
print(f'True minimum near theta=3 (ignoring sin perturbation)')
print(f'SGD  final theta: {sgd_traj[-1]:.6f}, loss: {loss(sgd_traj[-1]):.6f}')
print(f'Adam final theta: {adam_traj[-1]:.6f}, loss: {loss(adam_traj[-1]):.6f}')

if HAS_MPL:
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))
    steps = np.arange(len(sgd_traj))
    axes[0].plot(steps, loss(sgd_traj), 'b-', lw=2, label='SGD')
    axes[0].plot(steps, loss(adam_traj), 'r-', lw=2, label='Adam')
    axes[0].set_xlabel('Step'); axes[0].set_ylabel('Loss')
    axes[0].set_title('Loss vs Training Step')
    axes[0].legend(); axes[0].set_yscale('log')

    theta_plot = np.linspace(-3, 5, 300)
    axes[1].plot(theta_plot, loss(theta_plot), 'k-', lw=2, label='Loss surface')
    axes[1].plot(sgd_traj[:50], loss(sgd_traj[:50]), 'b.-', ms=4, alpha=0.7, label='SGD path')
    axes[1].plot(adam_traj[:50], loss(adam_traj[:50]), 'r.-', ms=4, alpha=0.7, label='Adam path')
    axes[1].set_xlabel('theta'); axes[1].set_ylabel('Loss')
    axes[1].set_title('Optimizer Trajectories on Loss Surface (first 50 steps)')
    axes[1].legend()
    plt.tight_layout(); plt.show()

12. Backpropagation — Chain Rule on Computation Graphs

We implement a complete 2-layer network forward/backward pass, verify gradients against numerical estimates, and visualize gradient flow through layers.

Code cell 30

# === 12. Full 2-Layer Network Backprop ===

import numpy as np

def sigmoid(x): return 1/(1+np.exp(-x))
def sigmoid_prime(x): s = sigmoid(x); return s*(1-s)

class TwoLayerNet:
    def __init__(self, w1, b1, w2, b2):
        self.w1 = w1; self.b1 = b1
        self.w2 = w2; self.b2 = b2

    def forward(self, x, y):
        self.x = x
        self.z1 = self.w1*x + self.b1
        self.a1 = sigmoid(self.z1)
        self.z2 = self.w2*self.a1 + self.b2
        self.a2 = sigmoid(self.z2)
        self.loss = 0.5*(self.a2 - y)**2
        return self.loss

    def backward(self):
        # Layer 2
        dL_da2 = self.a2 - self.y if hasattr(self,'y') else None
        # Recompute with stored y
        return None  # see below

# Manual backprop
def backprop(x, y, w1, b1, w2, b2):
    # Forward
    z1 = w1*x + b1
    a1 = sigmoid(z1)
    z2 = w2*a1 + b2
    a2 = sigmoid(z2)
    L = 0.5*(a2 - y)**2

    # Backward
    dL_da2 = a2 - y
    da2_dz2 = sigmoid_prime(z2)
    dL_dz2 = dL_da2 * da2_dz2

    dL_dw2 = dL_dz2 * a1
    dL_db2 = dL_dz2

    dL_da1 = dL_dz2 * w2
    da1_dz1 = sigmoid_prime(z1)
    dL_dz1 = dL_da1 * da1_dz1

    dL_dw1 = dL_dz1 * x
    dL_db1 = dL_dz1

    return L, dict(w1=dL_dw1, b1=dL_db1, w2=dL_dw2, b2=dL_db2)

# Test values
x, y, w1, b1, w2, b2 = 1.5, 0.7, 2.0, -0.5, 1.2, 0.3
L, grads = backprop(x, y, w1, b1, w2, b2)

print('=== 2-Layer Network Gradient Check ===')
print(f'Loss: {L:.6f}')

# Numerical check via parameter perturbation
h = 1e-5
for param_name, param_val, idx in [('w1',w1,0),('b1',b1,1),('w2',w2,2),('b2',b2,3)]:
    params = [w1, b1, w2, b2]
    p_plus = params[:]; p_plus[idx] += h
    p_minus = params[:]; p_minus[idx] -= h
    L_p = backprop(x, y, *p_plus)[0]
    L_m = backprop(x, y, *p_minus)[0]
    numerical = (L_p - L_m) / (2*h)
    analytic = grads[param_name]
    ok = abs(analytic - numerical) < 1e-6
    print(f"  {'PASS' if ok else 'FAIL'} d{param_name}: analytic={analytic:.8f}, "
          f"numerical={numerical:.8f}")

13. Softmax and Cross-Entropy Gradient

The gradient of cross-entropy loss with respect to softmax logits has a beautifully simple form: L/zj=pjyj\partial\mathcal{L}/\partial z_j = p_j - y_j.

Code cell 32

# === 13. Softmax + Cross-Entropy Gradient ===

import numpy as np

def softmax(z):
    z_stable = z - np.max(z)  # numerical stability
    e = np.exp(z_stable)
    return e / e.sum()

def cross_entropy(p, y):
    return -np.sum(y * np.log(p + 1e-12))

def softmax_ce_grad(z, y):
    """Gradient of CE loss w.r.t. logits z — should equal p - y."""
    p = softmax(z)
    return p - y

# Test
K = 5
np.random.seed(42)
z = np.random.randn(K)
y = np.zeros(K); y[2] = 1.0  # true class = 2

p = softmax(z)
L = cross_entropy(p, y)
analytic_grad = softmax_ce_grad(z, y)

# Numerical gradient
h = 1e-5
numerical_grad = np.zeros(K)
for i in range(K):
    z_p = z.copy(); z_p[i] += h
    z_m = z.copy(); z_m[i] -= h
    numerical_grad[i] = (cross_entropy(softmax(z_p), y) - cross_entropy(softmax(z_m), y)) / (2*h)

print('=== Softmax + Cross-Entropy Gradient ===')
print(f'Logits z: {z}')
print(f'Probs  p: {p}')
print(f'Loss   L: {L:.6f}')
print(f'\nGradient comparison (should be p - y):')
print(f'  p - y:     {p - y}')
print(f'  Analytic:  {analytic_grad}')
print(f'  Numerical: {numerical_grad}')
err = np.linalg.norm(analytic_grad - numerical_grad)
print(f'\nPASS - gradient error: {err:.2e}' if err < 1e-5 else f'FAIL - error: {err:.2e}')

14. Vanishing Gradients — Quantitative Analysis

We simulate gradient propagation through LL sigmoid layers and observe exponential decay. This motivates ReLU and residual connections in deep networks.

Code cell 34

# === 14. Vanishing Gradient Simulation ===

import numpy as np

try:
    import matplotlib.pyplot as plt
    HAS_MPL = True
except:
    HAS_MPL = False

def sigmoid(x): return 1/(1+np.exp(-x))
def sigmoid_prime(x): s = sigmoid(x); return s*(1-s)
def relu_prime(x): return float(x > 0)

np.random.seed(42)
max_layers = 50

# Simulate gradient magnitude at layer 1 as function of depth L
sigmoid_grads = []
relu_grads = []

for L in range(1, max_layers+1):
    # Random pre-activations (sigmoid tends to saturate)
    z_sigmoid = np.random.randn(L) * 2  # some saturation
    z_relu = np.abs(np.random.randn(L))  # mostly positive

    grad_sig = np.prod([sigmoid_prime(z) for z in z_sigmoid])
    grad_relu = np.prod([relu_prime(z) for z in z_relu])

    sigmoid_grads.append(abs(grad_sig))
    relu_grads.append(abs(grad_relu))

layers = np.arange(1, max_layers+1)

print('=== Gradient Magnitude at Layer 1 vs Network Depth ===')
for L in [1, 5, 10, 20, 50]:
    print(f'  L={L:2d}: sigmoid={sigmoid_grads[L-1]:.2e}, relu={relu_grads[L-1]:.2e}')

if HAS_MPL:
    fig, ax = plt.subplots(figsize=(10, 6))
    ax.semilogy(layers, sigmoid_grads, 'b-', lw=2, label='Sigmoid activations')
    ax.semilogy(layers, relu_grads, 'r-', lw=2, label='ReLU activations')
    ax.axhline(1e-7, color='gray', ls=':', label='Float32 precision floor')
    ax.set_xlabel('Number of layers L')
    ax.set_ylabel('|Gradient at layer 1|')
    ax.set_title('Vanishing Gradient: Sigmoid vs ReLU')
    ax.legend()
    plt.tight_layout(); plt.show()
    print('\nConclusion: sigmoid gradients vanish exponentially;')
    print('ReLU maintains O(1) gradient for active units.')

15. Numerically Stable Derivative Computations

Floating-point arithmetic introduces subtle errors. We compare naive vs. stable implementations for sigmoid, log-sigmoid, and softmax.

Code cell 36

# === 15. Numerical Stability ===

import numpy as np

# Naive sigmoid — overflows for large negative x
def sigmoid_naive(x): return 1 / (1 + np.exp(-x))

# Stable sigmoid
def sigmoid_stable(x):
    return np.where(x >= 0,
                    1 / (1 + np.exp(-x)),
                    np.exp(x) / (1 + np.exp(x)))

# Naive log-sigmoid
def log_sigmoid_naive(x): return np.log(sigmoid_naive(x))

# Stable log-sigmoid
def log_sigmoid_stable(x): return -np.logaddexp(0, -x)

test_vals = [-1000, -100, -10, 0, 10, 100, 1000]

print('=== Sigmoid Stability ===')
print(f'{"x":>8} {"Naive":>15} {"Stable":>15} {"True":>15}')
for x in test_vals:
    naive = sigmoid_naive(np.float64(x))
    stable = sigmoid_stable(np.float64(x))
    true_val = 1/(1+np.exp(-np.float64(x))) if abs(x) < 700 else (1.0 if x > 0 else 0.0)
    print(f'{x:8.0f} {naive:15.8f} {stable:15.8f} {true_val:15.8f}')

print('\n=== Log-Sigmoid Stability ===')
print(f'{"x":>8} {"Naive":>15} {"Stable":>15}')
for x in [-100, -10, 0, 10, 100]:
    with np.errstate(divide='ignore', invalid='ignore'):
        naive = log_sigmoid_naive(np.float64(x))
    stable = log_sigmoid_stable(np.float64(x))
    print(f'{x:8.0f} {naive:15.8f} {stable:15.8f}')
print('\nNaive gives -inf or 0 for large |x|; stable is correct throughout.')

16. Logarithmic Differentiation

When the function is a product/quotient of many terms, taking ln\ln first and differentiating implicitly is often much simpler.

Code cell 38

# === 16. Logarithmic Differentiation ===

import numpy as np

def centered(f, x, h=1e-6): return (f(x+h) - f(x-h)) / (2*h)

# f(x) = x^x
# ln f = x ln x => f'/f = ln x + 1 => f' = x^x * (ln x + 1)
def f_xx(x): return x**x
def f_xx_prime(x): return x**x * (np.log(x) + 1)

x = 2.0
print(f'x^x derivative at x=2:')
print(f'  Analytic: {f_xx_prime(x):.8f}')
print(f'  Numerical: {centered(f_xx, x):.8f}')
print(f'  PASS: {abs(f_xx_prime(x) - centered(f_xx, x)) < 1e-5}')

# f(x) = (sin x)^x for x > 0
# ln f = x ln(sin x) => f'/f = ln(sin x) + x*cos(x)/sin(x)
def f_sinx_x(x): return np.sin(x)**x
def f_sinx_x_prime(x):
    return np.sin(x)**x * (np.log(np.sin(x)) + x*np.cos(x)/np.sin(x))

x = 1.5
print(f'\n(sin x)^x derivative at x=1.5:')
print(f'  Analytic: {f_sinx_x_prime(x):.8f}')
print(f'  Numerical: {centered(f_sinx_x, x):.8f}')
print(f'  PASS: {abs(f_sinx_x_prime(x) - centered(f_sinx_x, x)) < 1e-5}')

# Demonstrate for multi-factor expression
# g(x) = (x^3 * sqrt(x+1)) / (x^2+2)^4
def g(x): return (x**3 * np.sqrt(x+1)) / (x**2+2)**4
def g_prime(x):
    # ln g = 3 ln x + 0.5 ln(x+1) - 4 ln(x^2+2)
    # g'/g = 3/x + 1/(2(x+1)) - 8x/(x^2+2)
    return g(x) * (3/x + 1/(2*(x+1)) - 8*x/(x**2+2))

x = 1.5
print(f'\nMulti-factor g(x) at x=1.5:')
print(f'  Analytic: {g_prime(x):.8f}')
print(f'  Numerical: {centered(g, x):.8f}')
print(f'  PASS: {abs(g_prime(x) - centered(g, x)) < 1e-5}')

17. Inverse Function Theorem

If f(a)0f'(a)\neq 0, then f1f^{-1} is differentiable at b=f(a)b=f(a) with (f1)(b)=1/f(a)(f^{-1})'(b) = 1/f'(a). We use this to derive all inverse trig derivatives.

Code cell 40

# === 17. Inverse Function Theorem ===

import numpy as np

def centered(f, x, h=1e-6): return (f(x+h) - f(x-h)) / (2*h)

# arcsin: (arcsin x)' = 1/sqrt(1-x^2)
def arcsin_prime(x): return 1/np.sqrt(1-x**2)

# arctan: (arctan x)' = 1/(1+x^2)
def arctan_prime(x): return 1/(1+x**2)

test_pts = [0.0, 0.3, -0.5]
print('=== Inverse Trig Derivatives ===')
for x in test_pts:
    a_asin = arcsin_prime(x); n_asin = centered(np.arcsin, x)
    a_atan = arctan_prime(x); n_atan = centered(np.arctan, x)
    print(f'x={x:+.1f}: arcsin\' analytic={a_asin:.6f}, numerical={n_asin:.6f} '
          f'| arctan\' analytic={a_atan:.6f}, numerical={n_atan:.6f}')

print('\nAll PASS:', all(
    abs(arcsin_prime(x) - centered(np.arcsin, x)) < 1e-5 and
    abs(arctan_prime(x) - centered(np.arctan, x)) < 1e-5
    for x in test_pts
))

# IFT: verify (f^-1)'(f(x)) = 1/f'(x) for f = exp
print('\nIFT check: (ln)\' at y = e^x = f(x), should equal 1/f\'(x) = 1/e^x')
for x in [0.5, 1.0, 2.0]:
    y = np.exp(x)
    inv_prime_at_y = 1/y  # (ln)' at y
    ift_formula = 1/np.exp(x)  # 1/f'(x)
    print(f'  x={x}: (ln)\'(e^x)={inv_prime_at_y:.6f}, 1/e^x={ift_formula:.6f}')

18. Mean Value Theorem — Visualization

We visualize the MVT: for ff on [a,b][a,b], there exists cc where the tangent is parallel to the secant. Applications: monotonicity, Lipschitz bounds.

Code cell 42

# === 18. MVT Visualization ===

import numpy as np

try:
    import matplotlib.pyplot as plt
    HAS_MPL = True
except:
    HAS_MPL = False

def f(x): return np.sin(x) + 0.3*x
def fp(x): return np.cos(x) + 0.3

a, b = 0.5, 4.0
secant_slope = (f(b) - f(a)) / (b - a)

# Find c where f'(c) = secant_slope numerically
x_scan = np.linspace(a, b, 10000)
fp_vals = fp(x_scan)
# c where fp closest to secant_slope
c_idx = np.argmin(abs(fp_vals - secant_slope))
c_mvt = x_scan[c_idx]

print(f'f(x) = sin(x) + 0.3x on [{a}, {b}]')
print(f'Secant slope: {secant_slope:.4f}')
print(f'MVT point c = {c_mvt:.4f}, f\'(c) = {fp(c_mvt):.4f}')

if HAS_MPL:
    x_plot = np.linspace(0, 4.5, 300)
    fig, ax = plt.subplots(figsize=(10, 6))
    ax.plot(x_plot, f(x_plot), 'b-', lw=2, label='f(x)')

    # Secant line
    x_sec = np.linspace(a-0.2, b+0.2, 50)
    y_sec = f(a) + secant_slope*(x_sec - a)
    ax.plot(x_sec, y_sec, 'r-', lw=2, label=f'Secant (slope={secant_slope:.2f})')

    # Tangent at c
    x_tang = np.linspace(c_mvt-0.8, c_mvt+0.8, 50)
    y_tang = f(c_mvt) + fp(c_mvt)*(x_tang - c_mvt)
    ax.plot(x_tang, y_tang, 'g--', lw=2, label=f'Tangent at c={c_mvt:.2f}')

    ax.plot([a, b], [f(a), f(b)], 'ro', ms=8)
    ax.plot(c_mvt, f(c_mvt), 'g^', ms=10, label='MVT point c')
    ax.set_xlabel('x'); ax.set_ylabel('f(x)')
    ax.set_title('Mean Value Theorem')
    ax.legend()
    plt.tight_layout(); plt.show()

19. Derivatives and Taylor Approximation

The first-order Taylor approximation f(x)f(a)+f(a)(xa)f(x) \approx f(a) + f'(a)(x-a) is the derivative's most direct practical application. We verify approximation quality across multiple functions.

Code cell 44

# === 19. Linear (First-Order Taylor) Approximation ===

import numpy as np

try:
    import matplotlib.pyplot as plt
    HAS_MPL = True
except:
    HAS_MPL = False

def linear_approx(f, fp, a, x):
    return f(a) + fp(a)*(x - a)

functions = [
    ('sin(x)', np.sin, np.cos, np.pi/4),
    ('e^x', np.exp, np.exp, 0.0),
    ('ln(x)', np.log, lambda x: 1/x, 1.0),
    ('sqrt(x)', np.sqrt, lambda x: 1/(2*np.sqrt(x)), 4.0),
]

print('=== Linear Approximation Quality ===')
print(f'{"Function":>12} {"a":>6} {"x-a":>6} {"True f(x)":>12} {"Linear":>12} {"Error":>10}')

for name, f, fp, a in functions:
    for dx in [0.1, 0.5, 1.0]:
        x = a + dx
        true_val = f(x)
        lin_val = linear_approx(f, fp, a, x)
        error = abs(true_val - lin_val)
        print(f'{name:>12} {a:6.2f} {dx:6.2f} {true_val:12.6f} {lin_val:12.6f} {error:10.2e}')

print('\nPattern: error grows as O((x-a)^2) — the quadratic (second-order) term.')
print('The full story is in §04-Series-and-Sequences (Taylor series).')

20. Subgradients and Non-Smooth Functions

For non-differentiable convex functions (ReLU, |x|, L1 norm), subgradients generalize the derivative. Subgradient descent is used for L1 regularization.

Code cell 46

# === 20. Subgradients ===

import numpy as np

try:
    import matplotlib.pyplot as plt
    HAS_MPL = True
except:
    HAS_MPL = False

# Subgradient of |x| at x=0 is any value in [-1,1]
# For optimization: use sign(x), subgradient 0 at x=0 is standard choice

x = np.linspace(-2, 2, 400)
f_abs = np.abs(x)
f_relu = np.maximum(0, x)

# Supporting hyperplanes at x=0 for |x|
x_supp = np.linspace(-1.5, 1.5, 50)
subgrads_to_show = [-1.0, -0.5, 0.0, 0.5, 1.0]

if HAS_MPL:
    fig, axes = plt.subplots(1, 2, figsize=(13, 5))

    ax = axes[0]
    ax.plot(x, f_abs, 'b-', lw=3, label='|x|', zorder=3)
    colors = plt.cm.plasma(np.linspace(0.1, 0.9, len(subgrads_to_show)))
    for g, c in zip(subgrads_to_show, colors):
        y_supp = g * x_supp  # supporting line: f(0) + g*(x-0) = g*x
        ax.plot(x_supp, y_supp, '--', color=c, alpha=0.7, lw=1.5,
                label=f'g={g:+.1f}')
    ax.set_xlim(-1.5, 1.5); ax.set_ylim(-0.2, 1.5)
    ax.set_xlabel('x'); ax.set_title('Subgradients of |x| at x=0')
    ax.legend(fontsize=9)

    ax2 = axes[1]
    ax2.plot(x, f_relu, 'r-', lw=3, label='ReLU(x)')
    for g, c in zip([0.0, 0.5, 1.0], ['blue','green','orange']):
        y_supp = g * x_supp
        ax2.plot(x_supp, y_supp, '--', color=c, alpha=0.7, label=f'g={g:.1f}')
    ax2.set_xlim(-1.5, 1.5); ax2.set_ylim(-0.3, 1.5)
    ax2.set_xlabel('x'); ax2.set_title('Subgradients of ReLU at x=0')
    ax2.legend()
    plt.tight_layout(); plt.show()

# Soft-thresholding (proximal operator of lambda*|x|)
lam = 0.3
def soft_threshold(v, lam):
    return np.sign(v) * np.maximum(np.abs(v) - lam, 0)

v_test = np.array([-1.0, -0.2, 0.0, 0.15, 0.5, 1.0])
print('=== Soft-Thresholding (Proximal Operator of L1) ===')
print(f'lambda = {lam}')
for v in v_test:
    print(f'  v={v:+.2f} -> ST={soft_threshold(v, lam):+.2f}')

21. Modern Activations — SiLU, Swish, Mish

Post-ReLU activations used in modern transformers. SiLU/Swish = xσ(x)x \cdot \sigma(x); Mish = xtanh(softplus(x))x \cdot \tanh(\text{softplus}(x)).

Code cell 48

# === 21. Modern Activations ===

import numpy as np

try:
    import matplotlib.pyplot as plt
    from scipy.special import erf
    HAS_MPL = True
except:
    HAS_MPL = False

def sigmoid(x): return 1/(1+np.exp(-np.clip(x,-500,500)))

# SiLU / Swish
def swish(x): return x * sigmoid(x)
def swish_prime(x):
    s = sigmoid(x)
    return s + x * s * (1 - s)

# Mish
def mish(x): return x * np.tanh(np.log(1+np.exp(np.clip(x,-500,500))))
def mish_prime(x):
    sp = np.log(1+np.exp(np.clip(x,-500,500)))  # softplus(x)
    ts = np.tanh(sp)
    sig = sigmoid(x)
    return ts + x * (1 - ts**2) * sig

# GELU for comparison
def gelu(x): return x * 0.5 * (1 + erf(x/np.sqrt(2)))
def gelu_prime(x):
    Phi = 0.5 * (1 + erf(x/np.sqrt(2)))
    phi = np.exp(-x**2/2) / np.sqrt(2*np.pi)
    return Phi + x*phi

# Verify derivatives
def centered(f, x, h=1e-6): return (f(x+h) - f(x-h)) / (2*h)

x = 1.5
print('=== Modern Activation Derivative Verification at x=1.5 ===')
for name, fn, fn_prime in [('Swish', swish, swish_prime), ('Mish', mish, mish_prime),
                             ('GELU', gelu, gelu_prime)]:
    a = fn_prime(x); n = centered(fn, x)
    print(f"  {'PASS' if abs(a-n)<1e-5 else 'FAIL'} {name}: analytic={a:.6f}, numerical={n:.6f}")

x_plot = np.linspace(-3, 3, 400)
if HAS_MPL:
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))
    for fn, nm, c in [(swish,'Swish','b'), (mish,'Mish','r'), (gelu,'GELU','g'),
                       (lambda x: np.maximum(0,x), 'ReLU', 'k')]:
        axes[0].plot(x_plot, fn(x_plot), lw=2, color=c, label=nm)
    axes[0].set_ylim(-0.5, 3); axes[0].set_title('Modern Activations')
    axes[0].set_xlabel('x'); axes[0].legend()

    for fn, nm, c in [(swish_prime,'Swish\'','b'), (mish_prime,'Mish\'','r'),
                       (gelu_prime,'GELU\'','g')]:
        axes[1].plot(x_plot, fn(x_plot), lw=2, color=c, label=nm)
    axes[1].set_ylim(-0.2, 1.2); axes[1].set_title('Activation Derivatives')
    axes[1].set_xlabel('x'); axes[1].legend()
    plt.tight_layout(); plt.show()

22. Gradient Descent — Convergence Analysis

For LL-smooth functions, learning rate η<2/L\eta < 2/L guarantees descent. We verify the descent lemma numerically.

Code cell 50

# === 22. Gradient Descent Convergence ===

import numpy as np

try:
    import matplotlib.pyplot as plt
    HAS_MPL = True
except:
    HAS_MPL = False

# Quadratic: f(theta) = L/2 * theta^2, L-smooth with L = L
L_smooth = 5.0
def f_quad(theta): return (L_smooth/2) * theta**2
def grad_quad(theta): return L_smooth * theta

theta0 = 3.0
steps = 30

print(f'=== Gradient Descent on f(theta) = ({L_smooth}/2)*theta^2 ===')
print(f'L-smooth constant L = {L_smooth}')
print(f'Critical eta: 2/L = {2/L_smooth:.4f}')

learning_rates = [0.1, 0.3, 1/L_smooth, 0.35, 0.4]

if HAS_MPL:
    fig, ax = plt.subplots(figsize=(10, 6))

for eta in learning_rates:
    theta = theta0
    losses = [f_quad(theta)]
    for _ in range(steps):
        theta -= eta * grad_quad(theta)
        losses.append(f_quad(theta))
    converges = losses[-1] < 1e-6
    print(f'  eta={eta:.2f}: final loss={losses[-1]:.2e} ({"converges" if converges else "DIVERGES"})')
    if HAS_MPL:
        ax.semilogy(losses, lw=2, label=f'eta={eta:.2f}')

if HAS_MPL:
    ax.axhline(1e-6, color='gray', ls=':', label='Convergence threshold')
    ax.set_xlabel('Step'); ax.set_ylabel('Loss (log scale)')
    ax.set_title(f'GD on Quadratic (L={L_smooth}) — Effect of Learning Rate')
    ax.legend()
    plt.tight_layout(); plt.show()

print(f'\nConclusion: eta >= 2/L = {2/L_smooth:.2f} diverges; eta < 2/L converges.')

23. Score Function and Fisher Information

The derivative of the log-likelihood is the score. Its variance is the Fisher information, which connects calculus to statistical estimation and second-order optimization.

Code cell 52

# === 23. Score Function and Fisher Information ===

import numpy as np

# Gaussian family: p(x; mu) = (1/sqrt(2pi)) * exp(-(x-mu)^2/2)
# log p = const - (x-mu)^2/2
# score = d/dmu log p = x - mu
# Fisher info: E[(score)^2] = E[(x-mu)^2] = Var[X] = 1 (for standard normal)

np.random.seed(42)
mu_true = 2.0
n_samples = 10000
X = np.random.normal(mu_true, 1.0, n_samples)

# Compute score and Fisher info empirically
def score(x, mu): return x - mu
def log_likelihood(X, mu): return -0.5*np.sum((X - mu)**2) - len(X)*0.5*np.log(2*np.pi)

# MLE: dL/dmu = 0 => sum(x_i - mu) = 0 => mu = xbar
mu_mle = np.mean(X)

# Score at MLE should be ~0 (optimality condition)
score_at_mle = np.mean(score(X, mu_mle))

# Fisher info: E[(x-mu)^2] should be Var[X] = 1
fisher_empirical = np.mean(score(X, mu_true)**2)

print('=== Score Function and Fisher Information ===')
print(f'True mu: {mu_true}, MLE mu_hat: {mu_mle:.4f}')
print(f'Score at MLE (should be ~0): {score_at_mle:.6f}')
print(f'Empirical Fisher info: {fisher_empirical:.4f} (true: 1.0)')

# Fisher-Rao connection: Fisher info = -E[d^2 log p/dmu^2]
# d^2/dmu^2 log p = -1 for Gaussian, so E[-(-1)] = 1
fisher_via_hessian = -np.mean([-1.0 for _ in X])  # = 1
print(f'Fisher via Hessian: {fisher_via_hessian:.4f} (Fisher identity: I = -E[f\'\'])')

# Cramer-Rao bound: Var[mu_hat] >= 1/I(mu)
print(f'\nCramer-Rao bound: Var[MLE] >= 1/I = {1/fisher_empirical:.4f}')
print(f'Actual Var[X_bar] = 1/n = {1/n_samples:.4f} (achieves bound — MLE is efficient)')

Summary

This notebook covered the full theory of differentiation:

SectionKey Result
§1 IntuitionSecant → tangent line as h0h\to 0
§2 Definitionf(a)=limh0[f(a+h)f(a)]/hf'(a) = \lim_{h\to 0}[f(a+h)-f(a)]/h
§3 RulesPower, product, quotient, chain, elementary functions
§4 Chain Rule(fg)=f(g)g(f\circ g)' = f'(g)\cdot g' — backbone of backprop
§5 Implicitdy/dx=Fx/Fydy/dx = -F_x/F_y; linear approximation
§6 Second DerivativeConcavity, inflection, ff'' at critical points
§7 Extrema / MVTFirst/second derivative tests; Lipschitz bounds
§8 Activationsσ=σ(1σ)\sigma'=\sigma(1-\sigma), ReLU', GELU'; vanishing gradients
§9 Numerical DiffForward O(h)O(h), centered O(h2)O(h^2), optimal h105.3h\approx 10^{-5.3}
§10 ProofsProduct rule, chain rule, differentiable→continuous
§11 AdamFirst/second moment estimates; adaptive learning rates
§12 BackpropFull 2-layer network chain rule derivation and verification
§13 Softmax+CEL/zj=pjyj\partial\mathcal{L}/\partial z_j = p_j - y_j
§14 Vanishing GradExponential decay through sigmoid layers; ReLU advantage
§15 StabilityNumerically stable sigmoid, log-sigmoid, softmax

Next: Integration →

Skill Check

Test this lesson

Answer 4 quick questions to lock in the lesson and feed your adaptive practice queue.

--
Score
0/4
Answered
Not attempted
Status
1

Which module does this lesson belong to?

2

Which section is covered in this lesson content?

3

Which term is most central to this lesson?

4

What is the best way to use this lesson for real learning?

Your answers save locally first, then sync when account storage is available.
Practice queue