上一篇介绍了维纳滤波。它是第一个统计最优的线性滤波器,但受限于三个致命假设:信号平稳、离线处理、单变量。1960 年,鲁道夫·卡尔曼发表了《A New Approach to Linear Filtering and Prediction Problems》,用状态空间方法替代频域方法,一举突破了这三个限制。阿波罗登月的导航计算机、GPS 接收机的位置解算、自动驾驶的传感器融合、金融时间序列的在线平滑,都依赖卡尔曼滤波或其变体。
与维纳滤波的关系
维纳滤波在频域中设计最优滤波器,需要知道信号和噪声的完整功率谱密度,且只能离线处理。卡尔曼滤波把问题转换到时域:用状态方程描述系统动力学,用观测方程描述测量过程,以递推方式在每个时刻仅用当前观测更新估计。两者在平稳线性高斯的特殊情况下给出等价的结果,但卡尔曼滤波的适用范围远大于维纳滤波——它允许非平稳信号、多维耦合状态、实时在线计算。
状态空间模型
卡尔曼滤波将问题描述为一个离散线性动态系统。系统在时刻 \(k\) 的状态 \(x_k \in \mathbb{R}^n\) 既不可直接观测,又随时间演化且受过程噪声扰动。我们所能获得的只有含观测噪声的测量值 \(z_k \in \mathbb{R}^m\):
状态方程: $\(x_k = F_k x_{k-1} + B_k u_k + w_k, \quad w_k \sim \mathcal{N}(0, Q_k)\)$
观测方程: $\(z_k = H_k x_k + v_k, \quad v_k \sim \mathcal{N}(0, R_k)\)$
\(F_k\)(\(n \times n\))是状态转移矩阵,描述系统在没有噪声和输入时如何从上一状态演化到当前状态。\(H_k\)(\(m \times n\))是观测矩阵,描述状态如何映射到测量空间。过程噪声 \(w_k\) 和观测噪声 \(v_k\) 均为零均值高斯白噪声,互相独立,协方差矩阵分别为 \(Q_k\) 和 \(R_k\)。
预测-更新递推
卡尔曼滤波器以两步递推方式工作。
预测步——基于 \(k-1\) 时刻的后验估计 \(\hat{x}_{k-1|k-1}\) 和后验协方差 \(P_{k-1|k-1}\),计算 \(k\) 时刻的先验估计:
更新步——用时刻 \(k\) 的观测 \(z_k\) 修正先验估计:
\(K_k\) 称为卡尔曼增益矩阵(\(n \times m\))。\((z_k - H_k \hat{x}_{k|k-1})\) 称为新息或残差,代表观测与预期的偏离。
卡尔曼增益的直觉
以标量情况为例(\(n = m = 1\),\(F = H = 1\)),记 \(p = P_{k|k-1}\),则
当观测噪声方差 \(R\) 很大(测量不可靠)时,\(K \approx 0\),滤波器几乎忽略观测,后验约等于先验预测。当 \(R\) 很小(测量精确)时,\(K \approx 1\),滤波器充分信任观测。若过程噪声 \(Q\) 较大导致先验协方差 \(p\) 增大,\(K\) 也随之增大——滤波器意识到自己的预测不可靠,转而更多依赖观测。
贝叶斯视角
在高斯噪声假设下,预测步给出的 \(\hat{x}_{k|k-1}\) 和 \(P_{k|k-1}\) 恰好是先验分布 \(p(x_k \mid z_{1:k-1}) = \mathcal{N}(\hat{x}_{k|k-1}, P_{k|k-1})\) 的均值和协方差。更新步用贝叶斯公式将观测似然 \(p(z_k \mid x_k) = \mathcal{N}(H_k x_k, R_k)\) 与先验结合,所得的后验分布 \(p(x_k \mid z_{1:k})\) 的均值恰好是 \(\hat{x}_{k|k}\),协方差恰好是 \(P_{k|k}\)。因此卡尔曼滤波本质上是线性高斯动态系统的精确贝叶斯递推。
可视化
下图的 GIF 展示了滤波追踪的动态过程:随着新观测不断到达,滤波器的估计(红线)逐步收敛到真实轨迹(黑线),红色椭圆代表估计的不确定性,随时间逐渐缩小。

Python 实现
下面的代码实现二维平面中匀速运动目标的卡尔曼滤波跟踪。状态向量 \(x_k = [p_x, v_x, p_y, v_y]^T\) 包含两个方向的位置和速度,观测只能获取位置分量。
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.patches import Ellipse
import csv, os
np.random.seed(42)
os.makedirs('images', exist_ok=True)
dt = 1.0
F = np.array([[1, dt, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, dt],
[0, 0, 0, 1]])
H = np.array([[1, 0, 0, 0],
[0, 0, 1, 0]])
q = 0.05
Q = q * np.array([[dt**3/3, dt**2/2, 0, 0],
[dt**2/2, dt, 0, 0],
[0, 0, dt**3/3, dt**2/2],
[0, 0, dt**2/2, dt]])
R = np.diag([15, 15])
N = 80
x_true = np.zeros((N, 4))
z_obs = np.zeros((N, 2))
xk = np.array([0, 5, 0, 3])
for k in range(N):
xk = F @ xk + np.random.multivariate_normal(np.zeros(4), Q)
x_true[k] = xk
z_obs[k] = H @ xk + np.random.multivariate_normal(np.zeros(2), R)
x_est = np.zeros((N, 4))
P_est = np.zeros((N, 4, 4))
K_hist = np.zeros((N, 4, 2))
x = np.zeros(4)
P = np.eye(4) * 50
for k in range(N):
x_pred = F @ x
P_pred = F @ P @ F.T + Q
S = H @ P_pred @ H.T + R
K = P_pred @ H.T @ np.linalg.inv(S)
x = x_pred + K @ (z_obs[k] - H @ x_pred)
P = (np.eye(4) - K @ H) @ P_pred
x_est[k] = x
P_est[k] = P
K_hist[k] = K
fig, axes = plt.subplots(2, 2, figsize=(14, 12))
ax = axes[0, 0]
ax.plot(x_true[:, 0], x_true[:, 2], 'k-', lw=1.0, label='True Trajectory')
ax.scatter(z_obs[:, 0], z_obs[:, 1], s=8, c='gray', alpha=0.4, label='Obs')
ax.plot(x_est[:, 0], x_est[:, 2], 'r-', lw=1.8, label='KF Estimate')
for k in range(0, N, 5):
cov = P_est[k][[0,2]][:,[0,2]]
vals, vecs = np.linalg.eigh(cov)
ang = np.degrees(np.arctan2(vecs[1, 0], vecs[0, 0]))
ell = Ellipse((x_est[k,0], x_est[k,2]), 2*np.sqrt(vals[0]), 2*np.sqrt(vals[1]),
angle=ang, fc='red', alpha=0.06, ec='red', lw=0.3)
ax.add_patch(ell)
ax.set_xlabel('x'); ax.set_ylabel('y')
ax.set_title('Position Trajectory with Uncertainty Ellipses')
ax.legend(fontsize=8); ax.grid(alpha=0.3); ax.set_aspect('equal')
ax = axes[0, 1]
for d, label, ls in [(0, 'K pos x', '-'), (2, 'K pos y', '--')]:
ax.plot(K_hist[:, d, 0], ls=ls, lw=1.2, label=label)
ax.set_xlabel('k'); ax.set_ylabel('Gain')
ax.set_title('Kalman Gain Convergence'); ax.legend(); ax.grid(alpha=0.3)
ax = axes[1, 0]
for d, label, c in [(0, 'x Error', 'tab:orange'), (2, 'y Error', 'tab:green')]:
ax.plot(np.abs(x_true[:, d] - x_est[:, d]), color=c, lw=1.0, label=label)
ax.set_xlabel('k'); ax.set_ylabel('|Error|')
ax.set_title('Estimation Error'); ax.legend(); ax.grid(alpha=0.3)
ax = axes[1, 1]
rmse = np.sqrt(np.cumsum((x_true[:, 1] - x_est[:, 1])**2) / np.arange(1, N+1))
ax.plot(rmse, 'tab:purple', lw=1.5)
ax.set_xlabel('k'); ax.set_ylabel('RMSE')
ax.set_title('Velocity RMSE'); ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig('images/kalman_tracking.svg', format='svg', bbox_inches='tight')
plt.savefig('images/kalman_tracking.png', dpi=150, bbox_inches='tight')
plt.close()
with open('images/kalman_tracking_data.csv', 'w', newline='') as f:
w = csv.writer(f)
w.writerow(['k','true_x','true_y','obs_x','obs_y','est_x','est_y','err_x','err_y'])
for k in range(N):
w.writerow([k] + [f'{v:.4f}' for v in [
x_true[k,0], x_true[k,2], z_obs[k,0], z_obs[k,1],
x_est[k,0], x_est[k,2],
abs(x_true[k,0]-x_est[k,0]), abs(x_true[k,2]-x_est[k,2])]])
fig_gif, ax_gif = plt.subplots(figsize=(8, 6))
ax_gif.set_xlim(x_true[:,0].min()-10, x_true[:,0].max()+20)
ax_gif.set_ylim(x_true[:,2].min()-10, x_true[:,2].max()+20)
ax_gif.set_xlabel('x'); ax_gif.set_ylabel('y'); ax_gif.set_aspect('equal')
true_line, = ax_gif.plot([], [], 'k-', lw=1.0, label='True Trajectory')
meas_scat = ax_gif.scatter([], [], s=6, c='gray', alpha=0.4, label='Obs')
est_line, = ax_gif.plot([], [], 'r-', lw=1.8, label='KF Estimate')
ell_patch = Ellipse((0,0), 1, 1, angle=0, fc='red', alpha=0.1, ec='red', lw=0.5)
ax_gif.add_patch(ell_patch)
ax_gif.legend(loc='upper left')
def animate(k):
true_line.set_data(x_true[:k+1, 0], x_true[:k+1, 2])
meas_scat.set_offsets(z_obs[:k+1])
est_line.set_data(x_est[:k+1, 0], x_est[:k+1, 2])
cov = P_est[k][[0,2]][:,[0,2]]
ev, evec = np.linalg.eigh(cov)
if ev[0] > 0:
ell_patch.set_width(2*np.sqrt(ev[0]))
ell_patch.set_height(2*np.sqrt(ev[1]))
ell_patch.set_angle(np.degrees(np.arctan2(evec[1,0], evec[0,0])))
ell_patch.set_center((x_est[k,0], x_est[k,2]))
ax_gif.set_title(f'Kalman Filter Tracking k={k}')
return true_line, meas_scat, est_line, ell_patch
ani = FuncAnimation(fig_gif, animate, frames=range(0, N, 2),
interval=120, blit=False)
ani.save('images/滤波04-图1.gif', writer='pillow', fps=8, dpi=72)
plt.close()
print('Kalman filter outputs saved')
演化与局限
卡尔曼滤波相比维纳滤波是革命性的进步:它支持多维状态、实时递推、非平稳信号,且在线性高斯假设下是最优的。但"线性"这个假设本身就是局限。雷达测量的是距离和方位角——观测方程是非线性的。飞行器的动力学涉及姿态旋转——状态方程是非线性的。一旦 \(f\) 或 \(h\) 不是线性函数,标准卡尔曼方程中的矩阵乘法就不再适用。
最直接的推广思路是:把非线性函数在当前估计点做一阶泰勒展开,用雅可比矩阵替代线性模型中的 \(F_k\) 和 \(H_k\)。这就是扩展卡尔曼滤波(EKF),下一篇的主题。
参考文献
[1] arXiv:2203.06105 — A Summary on the UD Kalman Filter.
[2] arXiv:2305.00252 — Model-Based Monitoring and State Estimation for Digital Twins: The Kalman Filter.