The RLS Algorithm in Python: Recursive Least Squares, Its Equivalence to the Kalman Filter, and the Forgetting Factor

Implement the RLS adaptive filter from scratch via the matrix inversion lemma (Sherman-Morrison formula) using numpy.outer and numpy.eye. A 200-trial Monte Carlo study compares convergence speed against LMS, shows that RLS with forgetting factor lambda=1 is numerically identical to a Kalman filter, and quantifies the forgetting-factor trade-off between steady-state accuracy and tracking speed under non-stationary conditions.

Introduction

https://yuhi-sa.github.io/en/posts/20260310_adaptive_filter/1/ and https://yuhi-sa.github.io/en/posts/20260501_lms_nlms/1/ covered LMS and NLMS adaptive filters in depth. Both articles ended by noting that “RLS converges faster than LMS but at higher computational cost” and that “RLS is mathematically equivalent to a Kalman filter,” without deriving either claim. This article follows up: we derive RLS (Recursive Least Squares) from the matrix inversion lemma, implement it from scratch in Python, numerically confirm its exact equivalence to a Kalman filter under specific conditions, and quantify how the forgetting factor \(\lambda\) trades off steady-state accuracy against tracking speed.

Problem Setup: Exponentially Weighted Least Squares

Unlike LMS, which stochastically approximates the instantaneous gradient, RLS exactly minimizes the weighted sum of squared errors over all past observations. For an FIR filter of length \(M\) with coefficients \(\mathbf{w}\) , define the cost function at time \(n\) as

\[ J(\mathbf{w}, n) = \sum_{i=1}^{n} \lambda^{n-i} \bigl[d(i) - \mathbf{x}(i)^\top \mathbf{w}\bigr]^2 \tag{1} \]

where \(\lambda \in (0, 1]\) is the forgetting factor, exponentially discounting the contribution of older samples. \(\lambda = 1\) weights all samples equally (ordinary least squares); \(\lambda < 1\) emphasizes recent samples, giving an adaptive estimate.

Setting the derivative of \(J(\mathbf{w}, n)\) with respect to \(\mathbf{w}\) to zero yields the normal equations

\[ \mathbf{R}(n) \mathbf{w}(n) = \mathbf{r}(n), \qquad \mathbf{R}(n) = \sum_{i=1}^n \lambda^{n-i} \mathbf{x}(i)\mathbf{x}(i)^\top, \quad \mathbf{r}(n) = \sum_{i=1}^n \lambda^{n-i} \mathbf{x}(i) d(i) \tag{2} \]

Inverting \(\mathbf{R}(n)\) from scratch every step costs \(O(M^3)\) . The matrix inversion lemma below reduces this to an \(O(M^2)\) recursive update.

O(M²) Recursion via the Matrix Inversion Lemma

\(\mathbf{R}(n)\) updates recursively as

\[ \mathbf{R}(n) = \lambda \mathbf{R}(n-1) + \mathbf{x}(n)\mathbf{x}(n)^\top \tag{3} \]

Letting \(P(n) = \mathbf{R}(n)^{-1}\) and applying the Sherman-Morrison formula (matrix inversion lemma)

\[ (A + \mathbf{u}\mathbf{v}^\top)^{-1} = A^{-1} - \frac{A^{-1}\mathbf{u}\mathbf{v}^\top A^{-1}}{1 + \mathbf{v}^\top A^{-1}\mathbf{u}} \tag{4} \]

Verifying equation (4). This identity may look like it fell from the sky, but direct substitution confirms that the right-hand side really is the inverse of the left-hand side. Let \(s = 1 + \mathbf{v}^\top A^{-1}\mathbf{u}\) (a scalar) and write the right-hand side of equation (4) as \(B\) . Then

\[ (A + \mathbf{u}\mathbf{v}^\top) B = A A^{-1} - \frac{A A^{-1}\mathbf{u}\mathbf{v}^\top A^{-1}}{s} + \mathbf{u}\mathbf{v}^\top A^{-1} - \frac{\mathbf{u}\mathbf{v}^\top A^{-1}\mathbf{u}\mathbf{v}^\top A^{-1}}{s} \]

Since \(\mathbf{v}^\top A^{-1}\mathbf{u}\) is a scalar, it can be pulled out of the vector product; substituting \(\mathbf{v}^\top A^{-1}\mathbf{u} = s - 1\) gives

\[ (A + \mathbf{u}\mathbf{v}^\top) B = I - \frac{\mathbf{u}\mathbf{v}^\top A^{-1}}{s} + \mathbf{u}\mathbf{v}^\top A^{-1} - \frac{(s-1)\,\mathbf{u}\mathbf{v}^\top A^{-1}}{s} = I + \mathbf{u}\mathbf{v}^\top A^{-1}\cdot\frac{-1 + s - (s-1)}{s} = I \]

The numerator inside the last fraction is exactly \(0\) , so \((A+\mathbf{u}\mathbf{v}^\top)B = I\) holds exactly (\(B(A+\mathbf{u}\mathbf{v}^\top) = I\) follows the same way). This direct check guarantees that the recursions (5)-(7) obtained below by applying the Sherman-Morrison formula are exact identities, not approximations.

Applying equation (4) with \(A = \lambda \mathbf{R}(n-1)\) and \(\mathbf{u} = \mathbf{v} = \mathbf{x}(n)\) gives

\[ P(n) = \frac{1}{\lambda}\left[P(n-1) - \frac{P(n-1)\mathbf{x}(n)\mathbf{x}(n)^\top P(n-1)}{\lambda + \mathbf{x}(n)^\top P(n-1)\mathbf{x}(n)}\right] \tag{5} \]

The key point: \(P(n)\) is computed from \(P(n-1)\) using only matrix products and a scalar division, never a direct matrix inversion. Defining a Kalman-gain-like quantity

\[ \mathbf{k}(n) = \frac{P(n-1)\mathbf{x}(n)}{\lambda + \mathbf{x}(n)^\top P(n-1)\mathbf{x}(n)} \tag{6} \]

lets us write equation (5) compactly as \(P(n) = \frac{1}{\lambda}\bigl[P(n-1) - \mathbf{k}(n)\mathbf{x}(n)^\top P(n-1)\bigr]\) . The weight update is

\[ e(n) = d(n) - \mathbf{x}(n)^\top \mathbf{w}(n-1), \qquad \mathbf{w}(n) = \mathbf{w}(n-1) + \mathbf{k}(n) e(n) \tag{7} \]

Per-step cost is dominated by computing \(\mathbf{k}(n)\) , giving \(O(M^2)\) — heavier than LMS/NLMS’s \(O(M)\) .

Python Implementation

import numpy as np

def rls_filter(xs, d, M, lam=1.0, delta=1.0):
    """Adaptive filtering via the RLS algorithm

    Parameters
    ----------
    xs : ndarray, shape (N, M)
        Input vector at each time step
    d : ndarray, shape (N,)
        Desired signal
    M : int
        Filter length
    lam : float
        Forgetting factor (0 < lam <= 1)
    delta : float
        Regularization for the initial covariance (P(0) = I / delta)

    Returns
    -------
    W : ndarray, shape (N, M)
        Filter coefficient trajectory over time
    """
    w = np.zeros(M)
    P = np.eye(M) / delta
    W = np.zeros((len(d), M))
    for n in range(len(d)):
        x = xs[n]
        Px = P @ x
        k = Px / (lam + x @ Px)
        e = d[n] - x @ w
        w = w + k * e
        P = (P - np.outer(k, x @ P)) / lam
        W[n] = w
    return W

np.outer(k, x @ P) implements the rank-1 update term in equation (5); Px / (lam + x @ Px) implements the gain in equation (6).

Experiment 1: Convergence Speed vs. LMS

For an \(M=8\) -tap FIR system identification problem (SNR 20 dB), we compared LMS (step size \(\mu=0.05\) ) and RLS (\(\lambda=1\) ) learning curves averaged over 200 Monte Carlo trials. The full experiment, with a fixed random seed, is below — copy it and run it to reproduce the same numbers.

np.random.seed(42)
M, N, n_trials, snr_db = 8, 150, 200, 20

true_w = np.random.randn(M)
true_w /= np.linalg.norm(true_w)
noise_power = 1.0 / (10 ** (snr_db / 10))  # signal power normalized to 1

lms_sq_err = np.zeros(N)
rls_sq_err = np.zeros(N)
for trial in range(n_trials):
    xs = np.random.randn(N, M)
    d = xs @ true_w + np.random.randn(N) * np.sqrt(noise_power)
    W_lms = lms_filter(xs, d, M, mu=0.05)
    W_rls = rls_filter(xs, d, M, lam=1.0, delta=1.0)
    lms_sq_err += np.sum((W_lms - true_w) ** 2, axis=1)
    rls_sq_err += np.sum((W_rls - true_w) ** 2, axis=1)

lms_mse = lms_sq_err / n_trials
rls_mse = rls_sq_err / n_trials

(lms_filter is the standard LMS update w += mu * e * x.) Running it gives:

Sample \(n\)LMS MSERLS MSE
200.20530.0148
500.02410.0025
1000.00310.0010

Samples needed to reach twice the noise floor (MSE ≈ 0.02): LMS took 54 samples, RLS took 18 — roughly a third as many. This numerically confirms the textbook claim that LMS converges linearly (rate depends on the input’s eigenvalue spread) while RLS converges quadratically (reaches the normal-equation solution in the shortest possible time, independent of input statistics). The trade-off is RLS’s \(O(M^2)\) per-step cost versus LMS’s \(O(M)\) , which drives the choice between them in practice. The left panel of the figure below plots these two learning curves directly (the right panel corresponds to the forgetting-factor experiment in the next section).

RLS vs. LMS convergence speed, and the effect of the forgetting factor λ

RLS’s Equivalence to the Kalman Filter

Recall the Kalman filter update equations (prediction, innovation, gain, update) derived in https://yuhi-sa.github.io/en/posts/20260224_kalman_filter/1/. RLS can be formulated as a special case of the Kalman filter:

RLS as a state-space model:

Kalman filter symbolRLS correspondenceMeaning
State \(\mathbf{x}_k\)Filter coefficients \(\mathbf{w}\)The parameter being estimated
Transition \(A\)\(I\) (identity)Coefficients assumed constant over time
Process noise \(Q\)\(0\)The true coefficients never change
Observation matrix \(H\)\(\mathbf{x}(n)^\top\)The time-varying input vector
Observation noise \(R\)\(\lambda\) (constant)The forgetting factor plays the role of observation variance
Initial covariance \(P_0\)\(I / \delta\)Matches RLS’s regularization parameter

Writing out the Kalman filter update equations (\(K_k = P_{k|k-1}H^\top S_k^{-1}\) , etc.) under this mapping reproduces the RLS update equations (6)-(7) exactly. We implemented both and applied them to the same data to compare the coefficient trajectories directly.

def kalman_param_filter(xs, d, M, R=1.0, delta=1.0):
    w = np.zeros(M)
    P = np.eye(M) / delta
    W = np.zeros((len(d), M))
    for n in range(len(d)):
        x = xs[n]
        Px = P @ x
        S = x @ Px + R          # innovation covariance
        K = Px / S               # Kalman gain
        e = d[n] - x @ w
        w = w + K * e
        P = P - np.outer(K, x @ P)
        W[n] = w
    return W

Running both on the same \(M=4\) -tap, 500-sample system identification problem with matched \(\lambda = 1\) , \(R = 1\) , and \(\delta\) , and np.random.seed(0) for reproducibility. The complete, runnable code that reproduces the numbers below is:

np.random.seed(0)
M, N = 4, 500
true_w = np.array([1.0, -0.5, 0.3, 0.2])
xs = np.random.randn(N, M)
d = xs @ true_w + np.random.randn(N) * 1.0  # R=1, so observation noise std is 1

W_rls = rls_filter(xs, d, M, lam=1.0, delta=1.0)
W_kf = kalman_param_filter(xs, d, M, R=1.0, delta=1.0)
print(f"max |RLS - KF| over all n, all taps (lambda=1, R=1): {np.max(np.abs(W_rls - W_kf))}")
print(f"final RLS weights: {W_rls[-1]}")
print(f"final KF  weights: {W_kf[-1]}")
print(f"true weights     : {true_w}")
max |RLS - KF| over all n, all taps (lambda=1, R=1): 0.0
final RLS weights: [ 0.9475194  -0.44072455  0.3474052   0.23557128]
final KF  weights: [ 0.9475194  -0.44072455  0.3474052   0.23557128]
true weights     : [ 1.  -0.5  0.3  0.2]

The two trajectories are bit-for-bit identical at every time step and every tap, numerically confirming that RLS is a special case of the Kalman filter. This equivalence is practically useful: instead of tuning the forgetting factor, one can switch to the more general Kalman-filter framework and model non-stationarity explicitly through the process noise \(Q\) .

This equivalence continues to be an active research topic in the 2020s. Lai and Bernstein (2024) introduce a “Kalman filter least squares” (KFLS) cost function whose recursive minimization unifies generalized forgetting RLS: not just the \(\lambda=1\) case verified numerically above, but a broad family of RLS forgetting extensions can all be derived as special cases of Kalman filtering. Building on this unified view, they construct a new class of adaptive Kalman filters and show, in a mass-spring-damper numerical example subject to intermittent, unmodeled collisions, that this approach improves state estimation over conventional RLS. The RLS-Kalman equivalence verified numerically in this article corresponds to the simplest special case of their unifying theory (\(Q=0\) , constant observation noise).

The Forgetting Factor λ: Steady State vs. Tracking Speed

The intuition behind \(\lambda < 1\) is that it approximates a Kalman filter with \(Q > 0\) . To test this, we constructed a non-stationary system whose true coefficients jump abruptly partway through (at sample 1000 out of \(N=1200\) , from \([1.0, -0.5, 0.3, 0.2]\) to \([-0.8, 0.6, -0.2, 0.5]\) ) and ran RLS with several values of \(\lambda\) . A single run can be non-monotonic due to observation noise, so we average over 200 Monte Carlo trials seeded from np.random.seed(1). The complete, runnable code that reproduces the numbers below is:

np.random.seed(1)
M, N, jump_n, n_trials = 4, 1200, 1000, 200
true_w1 = np.array([1.0, -0.5, 0.3, 0.2])
true_w2 = np.array([-0.8, 0.6, -0.2, 0.5])
true_w_t = np.tile(true_w1, (N, 1))
true_w_t[jump_n:] = true_w2  # coefficients jump at sample 1000

lams = [1.000, 0.995, 0.980, 0.900]
steady_acc = {lam: 0.0 for lam in lams}
after_acc = {lam: 0.0 for lam in lams}
for trial in range(n_trials):
    xs = np.random.randn(N, M)
    d = np.sum(xs * true_w_t, axis=1) + np.random.randn(N) * 1.0
    for lam in lams:
        W = rls_filter(xs, d, M, lam=lam, delta=1.0)
        err = np.sum((W - true_w_t) ** 2, axis=1)  # ||w(n) - w*(n)||^2
        steady_acc[lam] += np.mean(err[jump_n - 50:jump_n])  # mean error over the 50 samples before the jump
        after_acc[lam] += err[jump_n + 9]                     # error 10 samples after the jump

for lam in lams:
    print(f"lambda={lam}: steady={steady_acc[lam]/n_trials:.4f}  after10={after_acc[lam]/n_trials:.4f}")
\(\lambda\)Steady-state error before the jumpError 10 samples after the jump
1.0000.00444.7116
0.9950.00984.4069
0.9800.03983.4944
0.9000.22201.3792

\(\lambda = 1\) gives the smallest steady-state error (statistically most efficient, since it uses all past data equally) but tracks the change most slowly — just 10 samples after the jump it has barely moved toward the new value. Conversely, \(\lambda = 0.9\) has a roughly 50x larger steady-state error (effectively using only the last ~10 samples) but tracks changes fastest: its error 10 samples after the jump is already less than a third of \(\lambda=1\) ’s. This experiment quantitatively confirms that \(\lambda\) directly trades off steady-state accuracy against the ability to track a non-stationary environment. The right panel of the figure above plots the coefficient error \(\lVert \mathbf{w}(n) - \mathbf{w}^*(n) \rVert^2\) around the jump for these four values of \(\lambda\) : smaller \(\lambda\) decays the post-jump error much faster (faster tracking), at the cost of a visibly noisier steady-state floor before the jump.

Computational Cost vs. LMS/NLMS

Reproducing the comparison table from https://yuhi-sa.github.io/en/posts/20260310_adaptive_filter/1/:

PropertyLMSNLMSRLS
Cost per step\(O(M)\)\(O(M)\)\(O(M^2)\)
Memory\(O(M)\)\(O(M)\)\(O(M^2)\)
Convergence speedSlow (linear)ModerateFast (quadratic)
Steady-state errorDepends on step sizeDepends on step sizeSmall (depends on \(\lambda\) )
Numerical stabilityHighHigh\(P\) can diverge — watch for it

Because RLS iteratively updates the matrix \(P\) , finite-precision arithmetic can accumulate rounding errors that eventually cause divergence. The next section covers this pitfall together with several other practical edge cases.

Practical Pitfalls and Edge Cases

The recursive update derived via the matrix inversion lemma is mathematically exact, but real implementations and deployments must watch for the following edge cases.

1. Numerical divergence of \(P\) . Finite-precision arithmetic accumulates rounding error, and \(P(n)\) — which is supposed to stay symmetric positive-definite — can become asymmetric or non-positive-definite and diverge. The standard fixes are to symmetrize \(P \leftarrow (P + P^\top)/2\) every step, or to use QR- or UD-decomposition-based (Bierman) RLS variants. The plain implementation in this article (a direct transcription of equation (5)) is pedagogical; embedded applications running continuously for long periods need these safeguards.

2. Forgetting-factor “windup.” When \(\lambda < 1\) and the input is weak or absent for a stretch (no excitation, or a constant input), equation (5) keeps multiplying \(P\) by \(1/\lambda\) with no new information to counteract the growth, so \(P(n)\) grows exponentially. This is known as covariance windup. When strong excitation resumes, the inflated \(P\) produces an oversized Kalman gain \(\mathbf{k}(n)\) , and the coefficients can swing wildly for a few steps. Mitigations include capping \(\operatorname{tr} P(n)\) , directional forgetting (applying the forgetting only along excited directions), or switching to a variable forgetting factor that adapts \(\lambda\) based on the size of the innovation.

3. Lack of persistent excitation. If the input vector \(\mathbf{x}(n)\) only varies within a subspace (a constant input, a single sinusoid, or a rank-deficient regressor in general), \(\mathbf{R}(n)\) becomes singular or ill-conditioned along the unexcited directions, so the corresponding coefficient components are not uniquely identifiable. With \(\lambda = 1\) the estimate along those directions simply freezes; with \(\lambda < 1\) , the uncertainty (the corresponding eigenvalues of \(P\) ) keeps growing unchecked in the absence of new information — the same windup problem as above. It is worth verifying at design time that the filter’s input excites all tap directions sufficiently (i.e., is not spectrally too narrow).

4. Choosing the initial value \(\delta\) . \(\delta\) in \(P(0) = I/\delta\) regularizes the early iterations, when \(n < M\) and \(\mathbf{R}(n)\) is still rank-deficient (fewer observations than taps). Too large a \(\delta\) (too small a \(P(0)\) ) biases the early coefficient estimates toward zero; too small a \(\delta\) (too large a \(P(0)\) ) makes \(\mathbf{k}(n)\) oversized in the first few steps, causing oscillation. With unit-variance inputs, as in this article’s experiments, \(\delta = 1\) is a convenient default, and its influence vanishes once \(n \gg M\) .

5. A practical range for \(\lambda\) . Given the steady-state/tracking trade-off from the previous section, practitioners typically tune \(\lambda\) within \([0.95, 0.999]\) ; going below \(0.9\) is reserved for applications that must track very fast non-stationarity. Lowering \(\lambda\) too far shrinks the effective memory length (roughly \(1/(1-\lambda)\) samples) and makes the filter oversensitive to noise.

Which to Use

ScenarioRecommendationReason
Small tap count (\(M \lesssim 20\) ), fast convergence requiredRLSQuadratic convergence reaches the normal-equation solution fastest
Large tap count / embedded, low-resource targetsLMS / NLMS\(O(M)\) keeps compute and memory bounded
Mildly non-stationary environmentRLS (\(\lambda < 1\) )Forgetting factor tunes the steady-state/tracking trade-off
Large, abrupt environmental changesKalman filterProcess noise \(Q\) can be modeled explicitly
Long-running continuous operation (stability matters)NLMSNo risk of \(P\) divergence, and the implementation is simpler

References

  • Haykin, S. (2014). Adaptive Filter Theory (5th ed.). Pearson. Chapters 9-13.
  • Sayed, A. H. (2008). Adaptive Filters. Wiley-IEEE Press. Chapter 5 (RLS).
  • Kalman, R. E. (1960). A new approach to linear filtering and prediction problems. Journal of Basic Engineering, 82(1), 35-45.
  • Haykin, S. (2001). Kalman Filtering and Neural Networks. Wiley. Chapter 1 (RLS-Kalman equivalence).
  • Lai, B., & Bernstein, D. S. (2024). Adaptive Kalman Filtering Developed from Recursive Least Squares Forgetting Algorithms. arXiv:2404.10914.