Extended Kalman Filter (EKF): Theory and Python Implementation

Extended Kalman Filter (EKF) explained: Jacobian-based linearization theory, prediction and update equations, and a Python implementation with a nonlinear tracking example compared to the standard Kalman Filter.

What is the Extended Kalman Filter?

The Kalman Filter (KF) is the optimal state estimator for linear Gaussian systems, but most real-world systems are nonlinear. The Extended Kalman Filter (EKF) extends the KF framework to nonlinear systems by linearizing the nonlinear functions around the current estimate using Jacobian matrices (first-order Taylor expansion).

EKF is the most fundamental nonlinear filtering technique and serves as the starting point for more advanced methods:

  • EKF: First-order linearization via Jacobian matrices
  • UKF: Direct approximation of nonlinear transforms using sigma points (Unscented Transformation)
  • CKF : Sigma point selection based on the spherical-radial cubature rule
  • Particle Filter : Monte Carlo sampling to approximate arbitrary distributions

Nonlinear State-Space Model

Consider the following nonlinear state-space model:

\[ \mathbf{x}_k = f(\mathbf{x}_{k-1}) + \mathbf{w}_{k-1}, \quad \mathbf{w}_{k-1} \sim \mathcal{N}(\mathbf{0}, \mathbf{Q}) \tag{1} \] \[ \mathbf{z}_k = h(\mathbf{x}_k) + \mathbf{v}_k, \quad \mathbf{v}_k \sim \mathcal{N}(\mathbf{0}, \mathbf{R}) \tag{2} \]

Here \(f\) is the nonlinear state transition function and \(h\) is the nonlinear observation function. In the linear KF, these are expressed as matrices (\(f(\mathbf{x}) = \mathbf{A}\mathbf{x}\) and \(h(\mathbf{x}) = \mathbf{H}\mathbf{x}\) ), but in the EKF they are general nonlinear functions.

Linearization via Jacobian Matrices

The key idea of the EKF is to perform a Taylor expansion of the nonlinear functions \(f\) and \(h\) around the current estimate, retaining only the first-order terms.

The Jacobian of the state transition function \(f\) is computed at the previous estimate \(\hat{\mathbf{x}}_{k-1|k-1}\) :

\[ \mathbf{F}_k = \frac{\partial f}{\partial \mathbf{x}}\bigg|_{\mathbf{x}=\hat{\mathbf{x}}_{k-1|k-1}} = \begin{bmatrix} \frac{\partial f_1}{\partial x_1} & \cdots & \frac{\partial f_1}{\partial x_n} \\\ \vdots & \ddots & \vdots \\\ \frac{\partial f_n}{\partial x_1} & \cdots & \frac{\partial f_n}{\partial x_n} \end{bmatrix} \tag{3} \]

The Jacobian of the observation function \(h\) is computed at the predicted state \(\hat{\mathbf{x}}_{k|k-1}\) :

\[ \mathbf{H}_k = \frac{\partial h}{\partial \mathbf{x}}\bigg|_{\mathbf{x}=\hat{\mathbf{x}}_{k|k-1}} \tag{4} \]

This linearization leads to the characteristic structure of the EKF: the Jacobian matrices \(\mathbf{F}_k, \mathbf{H}_k\) are used for covariance propagation, while the original nonlinear functions \(f, h\) are used for state and observation computation.

Why the KF Update Equations Still Apply: Deriving the Error Propagation

The previous section defined the Jacobians \(\mathbf{F}_k, \mathbf{H}_k\) somewhat by fiat. Here we derive, from the propagation of the estimation error itself, why this linearization lets us reuse Eq. (6) and Eqs. (8)-(11) — exactly the same update equations as the linear Kalman filter.

Define the estimation error \(\mathbf{e}_{k-1|k-1} = \mathbf{x}_{k-1} - \hat{\mathbf{x}}_{k-1|k-1}\) (zero mean, covariance \(\mathbf{P}_{k-1|k-1}\) ) and substitute it into the state transition (Eq. 1). By the multivariable Taylor theorem, expanding each component \(f_i\) of \(f\) around \(\hat{\mathbf{x}}_{k-1|k-1}\) up to second order gives

\[ f_i(\hat{\mathbf{x}}_{k-1|k-1} + \mathbf{e}_{k-1|k-1}) = f_i(\hat{\mathbf{x}}_{k-1|k-1}) + \nabla f_i^T \mathbf{e}_{k-1|k-1} + \frac{1}{2} \mathbf{e}_{k-1|k-1}^T \mathbf{H}_{f_i} \mathbf{e}_{k-1|k-1} + O(\|\mathbf{e}_{k-1|k-1}\|^3) \tag{12} \]

where \(\mathbf{H}_{f_i}\) is the Hessian of \(f_i\) . Stacking all components into vector form,

\[ \mathbf{x}_k = f(\hat{\mathbf{x}}_{k-1|k-1}) + \mathbf{F}_k \mathbf{e}_{k-1|k-1} + \mathbf{w}_{k-1} + \mathbf{r}_k \tag{13} \]

Here \(\mathbf{r}_k\) collects the truncated second-order-and-higher terms (each component being \(\frac{1}{2}\mathbf{e}^T \mathbf{H}_{f_i}\mathbf{e} + O(\|\mathbf{e}\|^3)\) ) — this is precisely the linearization error. Since the EKF prediction step is defined as \(\hat{\mathbf{x}}_{k|k-1} = f(\hat{\mathbf{x}}_{k-1|k-1})\) , the prediction error is

\[ \mathbf{e}_{k|k-1} := \mathbf{x}_k - \hat{\mathbf{x}}_{k|k-1} = \mathbf{F}_k \mathbf{e}_{k-1|k-1} + \mathbf{w}_{k-1} + \mathbf{r}_k \tag{14} \]

If we now neglect \(\mathbf{r}_k\) (which is exactly the EKF approximation), \(\mathbf{e}_{k|k-1} \approx \mathbf{F}_k \mathbf{e}_{k-1|k-1} + \mathbf{w}_{k-1}\) is precisely the error-propagation equation of a linear system with transition matrix \(\mathbf{F}_k\) and noise \(\mathbf{w}_{k-1}\) . Taking the covariance therefore gives

\[ \mathbf{P}_{k|k-1} = E[\mathbf{e}_{k|k-1}\mathbf{e}_{k|k-1}^T] \approx \mathbf{F}_k \mathbf{P}_{k-1|k-1} \mathbf{F}_k^T + \mathbf{Q} \tag{15} \]

which matches Eq. (6) exactly. In other words, Eq. (6) is not an ad-hoc approximation — it is the rigorous consequence of accepting the assumption that the error dynamics can be approximated by a first-order Taylor expansion. Expanding the observation model (Eq. 2) around \(\hat{\mathbf{x}}_{k|k-1}\) in the same way gives, for the innovation \(\mathbf{y}_k\) ,

\[ \mathbf{y}_k = \mathbf{z}_k - h(\hat{\mathbf{x}}_{k|k-1}) \approx \mathbf{H}_k \mathbf{e}_{k|k-1} + \mathbf{v}_k + \mathbf{s}_k \tag{16} \]

where \(\mathbf{s}_k\) is the truncated second-order-and-higher term of \(h\) . Neglecting \(\mathbf{s}_k\) recovers exactly the linear KF’s innovation model \(\mathbf{y}_k = \mathbf{H}_k \mathbf{e}_{k|k-1} + \mathbf{v}_k\) , from which the Kalman gain and covariance update equations, Eqs. (8)-(11), follow directly.

This derivation makes two points explicit:

  1. What the linearization error actually is: the truncated terms \(\mathbf{r}_k, \mathbf{s}_k\) are proportional to the curvature (Hessian) of the state transition and observation functions and to the squared error. For a scalar output, \(E[\mathbf{e}^T \mathbf{H}\mathbf{e}] = \mathrm{tr}(\mathbf{H}\mathbf{P})\) , which is generally nonzero. So unlike the zero-mean process/observation noise, the EKF implicitly carries a systematic bias.
  2. The necessary conditions for divergence: this bias grows (a) as the error covariance \(\mathbf{P}\) (roughly, the current estimation error) grows, and (b) as the curvature (Hessian norm) of \(f, h\) grows. The next section, “Demonstrating Linearization Error,” shows the EKF actually diverging once both conditions are met.

EKF Algorithm

Prediction Step

The state prediction uses the nonlinear function \(f\) directly:

\[ \hat{\mathbf{x}}_{k|k-1} = f(\hat{\mathbf{x}}_{k-1|k-1}) \tag{5} \]

The covariance prediction uses the Jacobian \(\mathbf{F}_k\) for linear approximation:

\[ \mathbf{P}_{k|k-1} = \mathbf{F}_k \mathbf{P}_{k-1|k-1} \mathbf{F}_k^T + \mathbf{Q} \tag{6} \]

Update Step

The observation residual (innovation) uses the nonlinear function \(h\) :

\[ \mathbf{y}_k = \mathbf{z}_k - h(\hat{\mathbf{x}}_{k|k-1}) \tag{7} \]

The innovation covariance and Kalman gain use the Jacobian \(\mathbf{H}_k\) :

\[ \mathbf{S}_k = \mathbf{H}_k \mathbf{P}_{k|k-1} \mathbf{H}_k^T + \mathbf{R} \tag{8} \] \[ \mathbf{K}_k = \mathbf{P}_{k|k-1} \mathbf{H}_k^T \mathbf{S}_k^{-1} \tag{9} \]

State and covariance update:

\[ \hat{\mathbf{x}}_{k|k} = \hat{\mathbf{x}}_{k|k-1} + \mathbf{K}_k \mathbf{y}_k \tag{10} \] \[ \mathbf{P}_{k|k} = (\mathbf{I} - \mathbf{K}_k \mathbf{H}_k) \mathbf{P}_{k|k-1} \tag{11} \]

The key difference from the standard KF is that the nonlinear functions \(f, h\) are used for state propagation and observation prediction, while the Jacobian matrices \(\mathbf{F}_k, \mathbf{H}_k\) are used for covariance propagation.

Python Implementation

We implement the EKF for a 2D tracking problem where a target moves in a plane and an observer at the origin measures range and bearing.

Problem Setup

The state vector is \(\mathbf{x} = [p_x, p_y, v_x, v_y]^T\) (position and velocity). The state transition follows a constant velocity model:

\[ f(\mathbf{x}) = \begin{bmatrix} p_x + v_x \Delta t \\\ p_y + v_y \Delta t \\\ v_x \\\ v_y \end{bmatrix} \]

The observation model is a nonlinear function where the observer at the origin measures range \(r\) and bearing \(\theta\) :

\[ h(\mathbf{x}) = \begin{bmatrix} \sqrt{p_x^2 + p_y^2} \\\ \arctan(p_y / p_x) \end{bmatrix} \]

The Jacobian of the observation function \(h\) is, with \(r = \sqrt{p_x^2 + p_y^2}\) :

\[ \mathbf{H} = \begin{bmatrix} p_x / r & p_y / r & 0 & 0 \\\ -p_y / r^2 & p_x / r^2 & 0 & 0 \end{bmatrix} \]

Model Definition

import numpy as np
import matplotlib.pyplot as plt

dt = 1.0  # time step

def f(x):
    """State transition function (constant velocity model)"""
    px, py, vx, vy = x
    return np.array([px + vx * dt, py + vy * dt, vx, vy])

def jacobian_F(x):
    """Jacobian of the state transition function"""
    return np.array([
        [1, 0, dt, 0],
        [0, 1, 0, dt],
        [0, 0, 1,  0],
        [0, 0, 0,  1]
    ])

def h(x):
    """Observation function (range and bearing)"""
    px, py = x[0], x[1]
    r = np.sqrt(px**2 + py**2)
    theta = np.arctan2(py, px)
    return np.array([r, theta])

def jacobian_H(x):
    """Jacobian of the observation function"""
    px, py = x[0], x[1]
    r = np.sqrt(px**2 + py**2)
    return np.array([
        [px / r,      py / r,  0, 0],
        [-py / r**2,  px / r**2, 0, 0]
    ])

EKF Implementation

class EKF:
    def __init__(self, x0, P0, Q, R):
        """
        Parameters
        ----------
        x0 : array, initial state estimate
        P0 : array, initial covariance matrix
        Q  : array, process noise covariance
        R  : array, observation noise covariance
        """
        self.x = x0.copy()
        self.P = P0.copy()
        self.Q = Q
        self.R = R

    def predict(self):
        """Prediction step (Eq. 5, 6)"""
        F = jacobian_F(self.x)
        self.x = f(self.x)           # Use nonlinear f for state prediction
        self.P = F @ self.P @ F.T + self.Q  # Use Jacobian F for covariance
        return self.x.copy()

    def update(self, z):
        """Update step (Eq. 7-11)"""
        H = jacobian_H(self.x)
        y = z - h(self.x)            # Use nonlinear h for innovation
        # Normalize bearing difference to [-pi, pi]
        y[1] = (y[1] + np.pi) % (2 * np.pi) - np.pi

        S = H @ self.P @ H.T + self.R
        K = self.P @ H.T @ np.linalg.inv(S)
        self.x = self.x + K @ y
        self.P = (np.eye(len(self.x)) - K @ H) @ self.P
        return self.x.copy()

Simulation

np.random.seed(42)
T = 100  # number of time steps

# Noise parameters
Q = np.diag([0.1, 0.1, 0.01, 0.01])
R = np.diag([0.5, 0.01])  # range noise, bearing noise

# True initial state (velocity set to produce a curved trajectory)
x_true = np.array([10.0, 0.0, 0.5, 1.0])

# Generate true trajectory and observations
true_states = [x_true.copy()]
measurements = []

for k in range(T):
    # Slowly rotate velocity direction (gentle curve)
    omega_true = 0.03
    vx, vy = x_true[2], x_true[3]
    x_true[2] = vx * np.cos(omega_true) - vy * np.sin(omega_true)
    x_true[3] = vx * np.sin(omega_true) + vy * np.cos(omega_true)

    x_true = f(x_true) + np.random.multivariate_normal(np.zeros(4), Q)
    true_states.append(x_true.copy())

    z = h(x_true) + np.random.multivariate_normal(np.zeros(2), R)
    measurements.append(z.copy())

true_states = np.array(true_states)
measurements = np.array(measurements)

# Run EKF
x0 = np.array([10.5, -0.5, 0.0, 0.0])
P0 = np.diag([2.0, 2.0, 1.0, 1.0])
ekf = EKF(x0, P0, Q, R)

estimates = [x0.copy()]
for k in range(T):
    ekf.predict()
    ekf.update(measurements[k])
    estimates.append(ekf.x.copy())

estimates = np.array(estimates)

# Convert observations to Cartesian coordinates for visualization
obs_x = measurements[:, 0] * np.cos(measurements[:, 1])
obs_y = measurements[:, 0] * np.sin(measurements[:, 1])

Visualization

plt.figure(figsize=(10, 8))
plt.plot(true_states[:, 0], true_states[:, 1], "b-",
         linewidth=1.5, label="True trajectory")
plt.scatter(obs_x, obs_y, c="gray", s=10, alpha=0.5,
            label="Measurements (Cartesian)")
plt.plot(estimates[:, 0], estimates[:, 1], "r--",
         linewidth=1.5, label="EKF estimate")
plt.plot(true_states[0, 0], true_states[0, 1], "go",
         markersize=10, label="Start")
plt.xlabel("x")
plt.ylabel("y")
plt.title("Extended Kalman Filter - Range-Bearing Tracking")
plt.legend()
plt.axis("equal")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("ekf_result.png", dpi=150)
plt.show()

# RMSE
rmse_x = np.sqrt(np.mean((true_states[1:, 0] - estimates[1:, 0])**2))
rmse_y = np.sqrt(np.mean((true_states[1:, 1] - estimates[1:, 1])**2))
print(f"Position RMSE: x={rmse_x:.4f}, y={rmse_y:.4f}")

Limitations of the EKF

While the EKF is the most widely used nonlinear filtering technique, it has several limitations:

  1. Linearization error: Only the first-order terms of the Taylor expansion are retained, so approximation accuracy degrades when nonlinearity is strong. The estimate can become unstable in regions where the observation function has high curvature.

  2. Sensitivity to initial estimate: If the initial estimate is far from the true state, the linearization becomes inaccurate and the filter may diverge.

  3. Analytic Jacobian requirement: For complex systems, deriving the Jacobian matrices of \(f\) and \(h\) analytically can be tedious and error-prone. Numerical differentiation can be used as an alternative but introduces a trade-off between computational cost and accuracy.

  4. Gaussian assumption: The EKF assumes the posterior distribution is Gaussian, making it unable to handle multimodal distributions.

To address these issues, the Unscented Transformation introduces sigma points that approximate the nonlinear transform directly without linearization (UKF). The CKF improves numerical stability by selecting sigma points based on the cubature rule, and the Particle Filter removes the Gaussian assumption entirely.

Demonstrating Linearization Error: A Case Where the EKF Diverges

The previous derivation showed that the EKF’s linearization error surfaces when “the error \(\mathbf{P}\) is large” and “the curvature (nonlinearity) of \(f, h\) is strong.” We now deliberately satisfy both conditions using a benchmark model that is standard in the nonlinear filtering literature (Kitagawa 1996; also used in Arulampalam et al.’s 2002 particle-filter tutorial) to demonstrate the EKF actually diverging.

Consider the following scalar nonlinear system:

\[ x_k = 0.5 x_{k-1} + \frac{25 x_{k-1}}{1+x_{k-1}^2} + 8\cos(1.2(k-1)) + w_{k-1} \tag{17} \] \[ z_k = \frac{x_k^2}{20} + v_k \tag{18} \]

The state transition function \(f\) is strongly nonlinear, and it matters that the observation function \(h(x)=x^2/20\) is an even function (\(h(x)=h(-x)\) ). Its Jacobian \(\partial h/\partial x = x/10\) approaches zero near \(x=0\) , so the EKF gets almost no information from the observation in that region. Moreover, because \(h\) is even, the observation alone cannot distinguish the sign of \(x\) , and the first-order linearization effectively “commits” to whichever sign is currently assumed. If the true state swings across zero, the EKF’s linear approximation locks onto the wrong sign along with the wrong Jacobian, the second-order terms neglected in Eqs. (15)-(16) dominate, and the estimate drifts away from the true trajectory without reconverging.

import numpy as np
import matplotlib.pyplot as plt

def f(x, k):
    """Strongly nonlinear state transition function (Eq. 17)"""
    return 0.5 * x + 25 * x / (1 + x**2) + 8 * np.cos(1.2 * k)

def df_dx(x, k):
    """Jacobian of f (scalar)"""
    return 0.5 + 25 * (1 - x**2) / (1 + x**2)**2

def h(x):
    """Even observation function (Eq. 18)"""
    return x**2 / 20.0

def dh_dx(x):
    """Jacobian of h. Approaches zero near x=0"""
    return x / 10.0

np.random.seed(3)
T = 50
Q = 10.0  # process noise variance
R = 1.0   # observation noise variance

# Generate the true trajectory and observations
x_true = 0.1
true_states = [x_true]
zs = []
for k in range(T):
    x_true = f(x_true, k) + np.random.normal(0, np.sqrt(Q))
    true_states.append(x_true)
    zs.append(h(x_true) + np.random.normal(0, np.sqrt(R)))
true_states = np.array(true_states)
zs = np.array(zs)

# Run the scalar EKF
x_est = 0.1
P = 5.0
ekf_est = [x_est]
for k in range(T):
    F = df_dx(x_est, k)
    x_pred = f(x_est, k)
    P_pred = F * P * F + Q

    H = dh_dx(x_pred)
    y = zs[k] - h(x_pred)
    S = H * P_pred * H + R
    K = P_pred * H / S
    x_est = x_pred + K * y
    P = (1 - K * H) * P_pred
    ekf_est.append(x_est)
ekf_est = np.array(ekf_est)

rmse = np.sqrt(np.mean((true_states - ekf_est)**2))
max_err = np.abs(true_states - ekf_est).max()
print(f"RMSE: {rmse:.4f}, max abs error: {max_err:.4f}")

Running this, the EKF’s RMSE was 12.27 and the maximum absolute error reached 52.29 (the state itself roughly ranges over \([-25,25]\) , so this error is very large relative to the state’s own scale). Looking closer, at \(k=10\) the true value is \(x_{10}=-8.54\) while the EKF estimate is \(+12.79\) — a clear sign flip — and around \(k=41\) the error peaks at about 52.

EKF divergence on a strongly nonlinear benchmark model

The left panel shows the true state and EKF estimate over time; the right panel shows the absolute error. Errors spike sharply around \(k=10, 20, 31\) -\(34, 41\) — each time the true state either passes near \(x\approx 0\) , where \(h\) ’s curvature is largest, or flips sign. The second-order term \(\mathbf{s}_k\) neglected in Eq. (16) grows with \(h\) ’s nonlinearity and the size of the error, causing the linear approximation to diverge from the actual observation model and the EKF to lose track of the true trajectory.

This benchmark is a one-dimensional scalar system, but as shown in the UKF article , the same mechanism (linearization error proportional to curvature times squared error) appears in multi-dimensional range-bearing tracking too: near the origin, where the observation function’s curvature is largest, the EKF’s error exceeds the UKF’s.

Iterated EKF (IEKF): Improving Accuracy by Iterating the Update

The EKF update in Eqs. (7)-(11) evaluates the observation Jacobian \(\mathbf{H}_k\) at a single point — the predicted state \(\hat{\mathbf{x}}_{k|k-1}\) . But the updated estimate \(\hat{\mathbf{x}}_{k|k}\) generally moves away from \(\hat{\mathbf{x}}_{k|k-1}\) , so evaluating \(\mathbf{H}_k\) at the prediction point is a mismatch relative to the point where it should ideally be evaluated (closer to the true state). The Iterated EKF (IEKF) resolves this mismatch by iterating the update step, progressively refining the linearization point.

The IEKF update step can be written using an iterate \(\boldsymbol{\eta}^{(i)}\) (with \(\boldsymbol{\eta}^{(0)} = \hat{\mathbf{x}}_{k|k-1}\) ):

\[ \mathbf{H}^{(i)} = \frac{\partial h}{\partial \mathbf{x}}\bigg|_{\mathbf{x}=\boldsymbol{\eta}^{(i)}} \tag{19} \] \[ \mathbf{K}^{(i)} = \mathbf{P}_{k|k-1} (\mathbf{H}^{(i)})^T \left[ \mathbf{H}^{(i)} \mathbf{P}_{k|k-1} (\mathbf{H}^{(i)})^T + \mathbf{R} \right]^{-1} \tag{20} \] \[ \boldsymbol{\eta}^{(i+1)} = \hat{\mathbf{x}}_{k|k-1} + \mathbf{K}^{(i)} \Big[ \mathbf{z}_k - h(\boldsymbol{\eta}^{(i)}) - \mathbf{H}^{(i)} \left( \hat{\mathbf{x}}_{k|k-1} - \boldsymbol{\eta}^{(i)} \right) \Big] \tag{21} \]

Eq. (21) re-linearizes the observation model around the current iterate \(\boldsymbol{\eta}^{(i)}\) and computes one Gauss-Newton step under that linear approximation. The term \(\mathbf{H}^{(i)}(\hat{\mathbf{x}}_{k|k-1} - \boldsymbol{\eta}^{(i)})\) corrects for the linearization point having drifted from the prediction. Iterating this to convergence (or a fixed number of times) and taking the final \(\hat{\mathbf{x}}_{k|k} = \boldsymbol{\eta}^{(i_{\max})}\) , the covariance is then updated as in the standard EKF using the converged Jacobian. The IEKF is essentially a Gauss-Newton iteration that maximizes the observation log-likelihood, and is known to give more accurate estimates than the standard EKF when \(h\) is strongly nonlinear and the predicted point is far from the true state (truncating at one iteration recovers the plain EKF).

Using the same range-bearing tracking scenario as the UKF and CKF articles (a target approaching to range 1 from the sensor, an initial estimate deliberately far from the truth to simulate re-acquisition, random seed 42), we compare the standard EKF against IEKF (5 iterations). This section is presented as self-contained code (function names match the UKF article ).

import numpy as np

def state_transition(x, dt):
    """State transition function (constant velocity model)"""
    px, py, vx, vy = x
    return np.array([px + vx * dt, py + vy * dt, vx, vy])

def jacobian_F(dt):
    return np.array([
        [1, 0, dt, 0],
        [0, 1, 0, dt],
        [0, 0, 1, 0],
        [0, 0, 0, 1],
    ])

def observation(x):
    """Observation function (range and bearing)"""
    px, py = x[0], x[1]
    r = np.sqrt(px**2 + py**2)
    theta = np.arctan2(py, px)
    return np.array([r, theta])

def jacobian_H(x):
    px, py = x[0], x[1]
    r = np.sqrt(px**2 + py**2)
    return np.array([
        [px / r,      py / r,    0, 0],
        [-py / r**2,  px / r**2, 0, 0],
    ])

def wrap(y):
    """Normalize the bearing residual to [-pi, pi]"""
    y = y.copy()
    y[1] = (y[1] + np.pi) % (2 * np.pi) - np.pi
    return y

# --- Simulation setup (identical to the UKF and CKF articles) ---
np.random.seed(42)
dt = 1.0
T = 80

t = np.arange(0, T + 1) * dt
px_true = np.full(T + 1, 1.0)
py_true = -10.0 + 0.25 * t
true_states = np.column_stack([
    px_true, py_true, np.zeros(T + 1), np.full(T + 1, 0.25)
])

Q = np.diag([0.05, 0.05, 0.01, 0.01])
R = np.diag([0.3, 0.2])

measurements = np.array([
    observation(true_states[k]) + np.random.multivariate_normal([0, 0], R)
    for k in range(1, T + 1)
])

x0 = np.array([6.0, -3.0, 0.0, 0.1])
P0 = np.diag([16.0, 16.0, 1.0, 1.0])
class EKF:
    def __init__(self, x0, P0, Q, R, dt):
        self.x = x0.copy(); self.P = P0.copy()
        self.Q, self.R, self.dt = Q, R, dt

    def predict(self):
        F = jacobian_F(self.dt)
        self.x = state_transition(self.x, self.dt)
        self.P = F @ self.P @ F.T + self.Q

    def update(self, z):
        H = jacobian_H(self.x)
        y = wrap(z - observation(self.x))
        S = H @ self.P @ H.T + self.R
        K = self.P @ H.T @ np.linalg.inv(S)
        self.x = self.x + K @ y
        self.P = (np.eye(4) - K @ H) @ self.P


class IEKF:
    """Iterated EKF: re-evaluates H at the iterate eta within the update step (Eqs. 19-21)"""
    def __init__(self, x0, P0, Q, R, dt, n_iter=5):
        self.x = x0.copy(); self.P = P0.copy()
        self.Q, self.R, self.dt, self.n_iter = Q, R, dt, n_iter

    def predict(self):
        F = jacobian_F(self.dt)
        self.x_pred = state_transition(self.x, self.dt)
        self.P_pred = F @ self.P @ F.T + self.Q
        self.x, self.P = self.x_pred.copy(), self.P_pred

    def update(self, z):
        eta = self.x_pred.copy()
        for _ in range(self.n_iter):
            H = jacobian_H(eta)
            S = H @ self.P_pred @ H.T + self.R
            K = self.P_pred @ H.T @ np.linalg.inv(S)
            innovation = wrap(z - observation(eta)) - H @ (self.x_pred - eta)
            eta = self.x_pred + K @ innovation  # Eq. 21
        self.x = eta
        H = jacobian_H(self.x)
        S = H @ self.P_pred @ H.T + self.R
        K = self.P_pred @ H.T @ np.linalg.inv(S)
        self.P = (np.eye(4) - K @ H) @ self.P_pred
ekf = EKF(x0.copy(), P0.copy(), Q, R, dt)
ekf_estimates = [x0.copy()]
for k in range(T):
    ekf.predict()
    ekf.update(measurements[k].copy())
    ekf_estimates.append(ekf.x.copy())
ekf_estimates = np.array(ekf_estimates)

iekf = IEKF(x0.copy(), P0.copy(), Q, R, dt, n_iter=5)
iekf_estimates = [x0.copy()]
for k in range(T):
    iekf.predict()
    iekf.update(measurements[k].copy())
    iekf_estimates.append(iekf.x.copy())
iekf_estimates = np.array(iekf_estimates)

def rmse(est):
    return np.sqrt(np.mean(
        np.sum((true_states[1:, :2] - est[1:, :2])**2, axis=1)))

print(f"EKF Position RMSE: {rmse(ekf_estimates):.4f}")
print(f"IEKF Position RMSE: {rmse(iekf_estimates):.4f}")

The standard EKF’s RMSE was 1.7155 (reproducing the UKF article ’s number with the same implementation and data), whereas the IEKF with 5 iterations achieved an RMSE of 1.4654 — a roughly 14.6% error reduction over the EKF. Interestingly, this is even smaller than the UKF’s RMSE (1.5489); using 3 iterations instead gives an RMSE of 1.4550 (improvement is limited for 1-2 iterations, plateauing from around 3 onward). This suggests that much of this observation model’s error comes from evaluating the Jacobian at a single, mismatched point, and that IEKF’s iterative re-linearization addresses that mismatch more effectively than the UKF’s sigma-point approximation on this particular problem. That said, note that the UKF and CKF approximate the entire nonlinear transform, including the state transition, whereas the IEKF only improves the linearization point of the observation update (this difference does not show up here because \(f\) happens to be linear in this problem).

Comparing EKF, UKF, and CKF: Accuracy and Computational Cost

The UKF article already verified EKF vs. UKF accuracy, and the CKF article verified the mathematical equivalence of UKF and CKF, both on the same range-bearing tracking scenario. Here we reuse the same scenario and the state_transition/observation/EKF/Q/R/dt/T/x0/P0/measurements/true_states defined in the previous section, define new UKF and CKF classes, and actually run all three methods to (1) confirm the previously reported accuracy is reproducible, and (2) measure computation time, which neither prior article reported.

import time

class UKF:
    def __init__(self, n, m, f, h, Q, R, alpha=1.0, beta=2, kappa=0):
        self.n, self.m, self.f, self.h = n, m, f, h
        self.Q, self.R = Q, R
        self.lam = alpha**2 * (n + kappa) - n
        self.gamma = np.sqrt(n + self.lam)
        self.Wm = np.full(2 * n + 1, 1 / (2 * (n + self.lam)))
        self.Wc = np.full(2 * n + 1, 1 / (2 * (n + self.lam)))
        self.Wm[0] = self.lam / (n + self.lam)
        self.Wc[0] = self.lam / (n + self.lam) + (1 - alpha**2 + beta)

    def sigma_points(self, x, P):
        L = np.linalg.cholesky(P)
        sigmas = np.zeros((2 * self.n + 1, self.n))
        sigmas[0] = x
        for i in range(self.n):
            sigmas[i + 1] = x + self.gamma * L[:, i]
            sigmas[i + 1 + self.n] = x - self.gamma * L[:, i]
        return sigmas

    def predict(self, x, P, dt):
        sigmas = self.sigma_points(x, P)
        sigmas_pred = np.array([self.f(s, dt) for s in sigmas])
        x_pred = np.dot(self.Wm, sigmas_pred)
        P_pred = self.Q.copy()
        for i in range(2 * self.n + 1):
            d = sigmas_pred[i] - x_pred
            P_pred += self.Wc[i] * np.outer(d, d)
        return x_pred, P_pred, sigmas_pred

    def update(self, x_pred, P_pred, sigmas_pred, z):
        sigmas_z = np.array([self.h(s) for s in sigmas_pred])
        z_pred = np.dot(self.Wm, sigmas_z)
        Pzz = self.R.copy()
        Pxz = np.zeros((self.n, self.m))
        for i in range(2 * self.n + 1):
            dz = wrap(sigmas_z[i] - z_pred)
            dx = sigmas_pred[i] - x_pred
            Pzz += self.Wc[i] * np.outer(dz, dz)
            Pxz += self.Wc[i] * np.outer(dx, dz)
        y = wrap(z - z_pred)
        K = Pxz @ np.linalg.inv(Pzz)
        return x_pred + K @ y, P_pred - K @ Pzz @ K.T


class CKF:
    def __init__(self, n, m, f, h, Q, R):
        self.n, self.m, self.f, self.h = n, m, f, h
        self.Q, self.R = Q, R
        self.w = 1.0 / (2 * n)

    def cubature_points(self, x, P):
        L = np.linalg.cholesky(self.n * P)
        pts = np.zeros((2 * self.n, self.n))
        for i in range(self.n):
            pts[i] = x + L[:, i]
            pts[self.n + i] = x - L[:, i]
        return pts

    def predict(self, x, P, dt):
        X = self.cubature_points(x, P)
        X_pred = np.array([self.f(s, dt) for s in X])
        x_pred = self.w * X_pred.sum(axis=0)
        P_pred = self.Q.copy()
        for i in range(2 * self.n):
            d = X_pred[i] - x_pred
            P_pred += self.w * np.outer(d, d)
        return x_pred, P_pred

    def update(self, x_pred, P_pred, z):
        X = self.cubature_points(x_pred, P_pred)
        Y = np.array([self.h(s) for s in X])
        y_pred = self.w * Y.sum(axis=0)
        S = self.R.copy()
        C = np.zeros((self.n, self.m))
        for i in range(2 * self.n):
            dy = wrap(Y[i] - y_pred)
            dx = X[i] - x_pred
            S += self.w * np.outer(dy, dy)
            C += self.w * np.outer(dx, dy)
        y = wrap(z - y_pred)
        K = C @ np.linalg.inv(S)
        return x_pred + K @ y, P_pred - K @ S @ K.T
def run_ekf():
    ekf = EKF(x0.copy(), P0.copy(), Q, R, dt)
    est = [x0.copy()]
    for k in range(T):
        ekf.predict(); ekf.update(measurements[k].copy())
        est.append(ekf.x.copy())
    return np.array(est)

def run_ukf():
    ukf = UKF(4, 2, state_transition, observation, Q, R)
    x_est, P_est = x0.copy(), P0.copy()
    est = [x_est.copy()]
    for k in range(T):
        x_pred, P_pred, sig = ukf.predict(x_est, P_est, dt)
        x_est, P_est = ukf.update(x_pred, P_pred, sig, measurements[k].copy())
        est.append(x_est.copy())
    return np.array(est)

def run_ckf():
    ckf = CKF(4, 2, state_transition, observation, Q, R)
    x_est, P_est = x0.copy(), P0.copy()
    est = [x_est.copy()]
    for k in range(T):
        x_pred, P_pred = ckf.predict(x_est, P_est, dt)
        x_est, P_est = ckf.update(x_pred, P_pred, measurements[k].copy())
        est.append(x_est.copy())
    return np.array(est)

ekf_est, ukf_est, ckf_est = run_ekf(), run_ukf(), run_ckf()
print(f"EKF RMSE: {rmse(ekf_est):.4f}")
print(f"UKF RMSE: {rmse(ukf_est):.4f}")
print(f"CKF RMSE: {rmse(ckf_est):.4f}")

n_repeat = 200
for name, fn in [("EKF", run_ekf), ("UKF", run_ukf), ("CKF", run_ckf)]:
    t0 = time.perf_counter()
    for _ in range(n_repeat):
        fn()
    t1 = time.perf_counter()
    print(f"{name}: {(t1 - t0) / n_repeat * 1000:.4f} ms/run "
          f"({(t1 - t0) / n_repeat / T * 1e6:.2f} us/step)")

The results (80 steps, random seed 42, averaged over 200 runs) are:

MethodPosition RMSETime per runTime per step
EKF1.71551.40 ms17.5 μs
UKF1.54899.00 ms112.5 μs
CKF1.56739.03 ms112.8 μs

The RMSE values — EKF 1.7155 and UKF 1.5489 — match the UKF article ’s numbers exactly, confirming reproducibility. The CKF’s RMSE here is 1.5673, close to but not identical to the UKF’s. As noted in the CKF article , the numerical equivalence between UKF and CKF depends on whether the update step regenerates cubature/sigma points from the predicted covariance or reuses the already-propagated points — a convention we did not align here.

The computation time, however, shows a clear difference: the EKF is about 6.4x faster than the UKF or CKF. This is because the EKF only needs to evaluate the Jacobian once for a state dimension of \(n=4\) , whereas the UKF and CKF must evaluate the nonlinear functions \(f, h\) at \(2n+1=9\) (UKF) or \(2n=8\) (CKF) sigma/cubature points per step, plus a Cholesky decomposition. This confirms, with actual measured time, the theoretical cost ordering shown in the UKF article ’s “Comparison with EKF” table (EKF: \(O(n^2)\) , UKF: \(O(n^3)\) ). Since the accuracy gain from UKF/CKF is under 10% here while the computational cost is more than 6x higher, the EKF can remain a practical choice when nonlinearity is mild or computing resources are constrained, such as real-time estimation on embedded hardware.

The EKF was proposed in the 1960s and is the most classical nonlinear filter, yet its simplicity and low computational cost keep it in wide use for SLAM, autonomous driving, and robot state estimation. A notable recent (2023 onward) research direction is not replacing the EKF but extending it by combining it with neural networks.

Liu, Lai, Bacsa, and Chatzi (2024) propose the “Neural EKF” in “Neural Extended Kalman Filters for Learning and Predicting Dynamics of Structural Systems” (Structural Health Monitoring), parameterizing the state transition and observation functions with neural networks. Unlike conventional variational approaches that train inference and dynamics models as separate networks, the Neural EKF embeds a neural network directly into the EKF’s sequential filtering structure and trains it end-to-end, improving the learning and prediction of complex physical system dynamics such as structures.

Eang and Lee (2024), in “An Integration of Deep Neural Network-Based Extended Kalman Filter (DNN-EKF) Method in Ultra-Wideband (UWB) Localization for Distance Loss Optimization” (Sensors), integrate a DNN with the EKF for indoor robot localization, reporting improved positioning accuracy through reduced UWB ranging error.

What these works share is that they keep the “linearization via Jacobians” framework derived in this article intact, while learning the nonlinear functions \(f, h\) (or a correction term) from data, in effect learning the curvature responsible for linearization error directly from observed data. Even more than half a century after its introduction, and even as the UKF, particle filters, and deep-learning-based methods have become widespread, the EKF continues to be chosen for real-time systems because of its low computational cost — and extending it remains an active area of research.

References

  • Thrun, S., Burgard, W., & Fox, D. (2005). “Probabilistic Robotics.” MIT Press.
  • Simon, D. (2006). “Optimal State Estimation: Kalman, H-infinity, and Nonlinear Approaches.” Wiley.
  • Arulampalam, M. S., Maskell, S., Gordon, N., & Clapp, T. (2002). “A Tutorial on Particle Filters for Online Nonlinear/Non-Gaussian Bayesian Tracking.” IEEE Transactions on Signal Processing, 50(2), 174-188.
  • Liu, W., Lai, Z., Bacsa, K., & Chatzi, E. (2024). “Neural Extended Kalman Filters for Learning and Predicting Dynamics of Structural Systems.” Structural Health Monitoring, 23(2), 1037-1052.
  • Eang, C., & Lee, S. (2024). “An Integration of Deep Neural Network-Based Extended Kalman Filter (DNN-EKF) Method in Ultra-Wideband (UWB) Localization for Distance Loss Optimization.” Sensors, 24(21), 6959.