Kiru Lab / Embodied Control: Kinematics and the Inverse Jacobian / Inverse Kinematics
Inverse Jacobian Methods
The centerpiece: solve the hard direction by iterating the easy one, exactly like gradient descent.
Hands-On Lab · about 55 minutes
You cannot invert the forward kinematics analytically for a general chain. But you can linearize it locally: near the current configuration, the Jacobian says how the hand moves per unit of joint motion. So take the error between where the hand is and where you want it, ask the Jacobian for the joint motion that would reduce that error, take a small step, and repeat. This is Newton-Raphson on a vector-valued function, and it is structurally identical to the gradient descent loop from track three.
import numpy as np
def inverse_kinematics(target, q0, lengths, damping=0.05,
alpha=1.0, tol=1e-4, max_iters=200):
"""Damped-least-squares IK. Returns (solution, error_history, converged)."""
q = np.array(q0, dtype=float)
history = []
for _ in range(max_iters):
error = target - forward_kinematics(q, lengths)
norm = float(np.linalg.norm(error))
history.append(norm)
if norm < tol:
return q, history, True
J = jacobian_analytic(q, lengths) # (2, n)
# Damped least squares (Levenberg-Marquardt):
# J^T (J J^T + lambda^2 I)^{-1}
# The damping term keeps the inverse finite when J loses rank. It trades
# exactness for stability — near a singularity you accept a slightly wrong
# direction instead of an unbounded joint velocity.
JJt = J @ J.T
damped = JJt + (damping ** 2) * np.eye(JJt.shape[0])
qdot = J.T @ np.linalg.solve(damped, error)
q = q + alpha * qdot
return q, history, FalseThree ways to invert, and when each is right
- Jacobian transpose: `qdot = J^T e`. Cheap, always stable, no matrix inverse — but it is gradient descent on squared error, so convergence is slow. Use when compute is scarce or robustness matters more than speed.
- Moore-Penrose pseudoinverse: `qdot = J^+ e`. Gives the minimum-norm exact solution, converges fast, and blows up near singularities. Use away from singular configurations.
- Damped least squares: the code above. Adds `lambda^2 I` before inverting, which bounds the solution near singularities at the cost of a small steady-state error. This is the production default, and the damping term is exactly ridge regularization from track three.
The damping constant here, the ridge penalty in regression, and weight decay in a neural network are the same mathematical device: add a small multiple of the identity to make an ill-conditioned problem well-posed. Recognizing that is worth more than memorizing any of the three.
Convergence behavior is worth watching directly. The error history should fall roughly geometrically; a plateau means you are at a local structure the linearization cannot escape, and oscillation means alpha is too large — the same two readings you learned to make from a training loss curve.
Hold on to
- IK solves the hard direction by iterating the easy one
- Damped least squares trades exactness for stability near singularities
- Damping, ridge, and weight decay are the same regularization
Work through
Try each one before opening the solution. Getting it wrong first is most of where the learning happens.
-
Implement all three inversion methods and compare iteration counts to reach 1e-4 on the same target.
Hint
Transpose is slowest, pseudoinverse fastest away from singularities, damped in between.
Solution
On a well-conditioned target the pseudoinverse typically converges in a handful of iterations, damped least squares in a few more (the damping deliberately shortens each step), and the transpose method in tens to hundreds — it is gradient descent, with gradient descent's convergence rate. Near a singularity the ordering reverses: the pseudoinverse becomes unstable while damping and transpose both stay bounded.
import numpy as np def ik(target, q0, lengths, mode="damped", damping=0.05, alpha=1.0, tol=1e-4, max_iters=500): q = np.array(q0, dtype=float) for i in range(max_iters): error = target - forward_kinematics(q, lengths) if np.linalg.norm(error) < tol: return q, i J = jacobian_analytic(q, lengths) if mode == "transpose": qdot = J.T @ error elif mode == "pinv": qdot = np.linalg.pinv(J) @ error else: JJt = J @ J.T qdot = J.T @ np.linalg.solve(JJt + damping ** 2 * np.eye(2), error) q = q + alpha * qdot return q, max_iters for mode in ("transpose", "pinv", "damped"): q, iters = ik(np.array([1.2, 0.8]), [0.3, 0.4], np.array([1.0, 1.0]), mode=mode) print(f"{mode:10} {iters} iterations")Check your work
Paste this after your own code. If it runs without raising, you have it.
import numpy as np lengths = np.array([1.0, 1.0]) q, iters = ik(np.array([1.2, 0.8]), [0.3, 0.4], lengths, mode="damped") assert np.linalg.norm(forward_kinematics(q, lengths) - np.array([1.2, 0.8])) < 1e-3 print("ok") -
Drive the arm along a straight line that passes near a singularity with damping 0 and 0.1. Plot peak joint velocity for each.
Hint
Aim the straight line so it passes close to full extension.
Solution
With damping 0 the peak joint velocity explodes near the singularity and the arm lurches. With damping 0.1 the peak stays bounded — usually within an order of magnitude of normal — at the cost of a small tracking error right at the closest approach, because the damped solution is deliberately not the exact one. That is the trade stated numerically: exactness for stability.
import numpy as np for damping in (0.0, 0.1): q, peak, max_err = np.array([0.2, 0.3]), 0.0, 0.0 for s in np.linspace(0.5, 1.95, 300): target = np.array([s, 0.05]) error = target - forward_kinematics(q, lengths) J = jacobian_analytic(q, lengths) JJt = J @ J.T qdot = J.T @ np.linalg.solve(JJt + damping ** 2 * np.eye(2), error) peak = max(peak, float(np.abs(qdot).max())) q = q + 0.2 * qdot max_err = max(max_err, float(np.linalg.norm(error))) print(f"damping={damping}: peak qdot={peak:.1f} max tracking error={max_err:.4f}") -
Animate the arm converging to a target and confirm the error history matches the shape you expected.
Hint
Plot the error history on a log y-axis.
Solution
A healthy run shows the error falling roughly geometrically — a straight line on a log axis — then flattening at the tolerance. A plateau above tolerance means the target is unreachable or you are stuck at a configuration the linearization cannot escape. Oscillation means alpha is too large. These are the same three readings you learned on a training loss curve in track three, and that is not a coincidence: it is the same algorithm.
import matplotlib.pyplot as plt q, history, converged = inverse_kinematics(np.array([1.2, 0.8]), [0.3, 0.4], lengths) plt.semilogy(history); plt.xlabel("iteration"); plt.ylabel("|error|"); plt.show() print("converged:", converged, "in", len(history), "iterations")
In the Bio Mirror
The Cerebellum and Internal ModelsSign in to track your progress through the lab.