Skip to content

Conversation

@pavelkomarov
Copy link
Collaborator

The tests are definitely going to fail, because I identified at least one bug that changes core logic, so the results will be a tiny bit numerically different. We'll check against improved unit tests once that's merged. This also addresses #97 some, but I'm afraid that one will take significantly more work if we want to actually properly extend this module. Even the linear KF in this module isn't calculating a derivative (mistake?), which makes me extra suspicious that it hasn't had any love in a long time.

- **x_hat** -- estimated (smoothed) x
- **dxdt_hat** -- estimated derivative of x
"""
w = np.arange(len(x)) / (len(x) - 1) # set up weights, [0., ... 1.0]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an easier way.

####################


def __kalman_forward_update__(xhat_fm, P_fm, y, u, A, B, C, R, Q):
Copy link
Collaborator Author

@pavelkomarov pavelkomarov Jun 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found making this a separate function to be unnecessary; its logic now lives in the forward filter.

else:
xhat_fp = xhat_fm
P_fp = (I - K_f@C)@P_fm
xhat_fm = A@xhat_fp + B@u
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some duplicated lines in here. I got rid of them.

:return:
"""
I = np.array(np.eye(A.shape[0]))
gammaW = np.array(np.eye(A.shape[0]))
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this supposed to do? It was doing nothing, so I got rid of it.

- **xhat_pre** -- a priori estimates of xhat, with axis=0 the batch dimension, so xhat[n] gets the nth step
- **xhat_post** -- a posteriori estimates of xhat
- **P_pre** -- a priori estimates of P
- **P_post** -- a posteriori estimates of P
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've renamed all these to be a little more explicit about what they are.

:param np.array u: optional control input
:param np.array B: optional control matrix
:return: tuple[np.array, np.array, np.array, np.array] of\n
- **xhat_pre** -- a priori estimates of xhat, with axis=0 the batch dimension, so xhat[n] gets the nth step
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I many places you weren't using 0 as the batch dimension. But axis 0 should always be the batch dimension so you can pull out a sample without having to do complicated slicing.

xhat_fp = xhat_fm + K_f@(y - C@xhat_fm)
P_fp = (I - K_f@C)@P_fm
xhat_fm = A@xhat_fp + B@u
P_fm = A@P_fp@A.T + gammaW@Q@gammaW.T
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order of these calls is inverted from how I tend to think about the KF. You can start on either the prediction or the update side, but I start on prediction in the paper, and so do the standard Wikipedia and many other pseudocodes for the KF. Thus, your _fm variables here contain the next time step's prediction, but they're indexed at this time step, which causes confusion when you go do your RTS implementation.

xhat_smooth = copy.copy(xhat_fp)
P_smooth = copy.copy(P_fp)
for t in range(N-2, -1, -1):
L = P_fp[t]@A.T@np.linalg.pinv(P_fm[t])
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this line is actually correct, because P_fm[t] holds $P_{t+1|t}$.

for t in range(N-2, -1, -1):
L = P_fp[t]@A.T@np.linalg.pinv(P_fm[t])
xhat_smooth[:, [t]] = xhat_fp[:, [t]] + L@(xhat_smooth[:, [t+1]] - xhat_fm[:, [t+1]])
P_smooth[t] = P_fp[t] - L@(P_smooth[t+1] - P_fm[t+1])
Copy link
Collaborator Author

@pavelkomarov pavelkomarov Jun 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this line is incorrect, because you're supposed to use $P_{t+1|t}$ here as well, which is stored in P_fm[t]. https://arc.aiaa.org/doi/10.2514/3.3166

This line is also wrong because you're subtracting the L part instead of adding it. So this isn't doing RTS smoothing at all. Serious bug.

#####################


def __constant_velocity__(x, dt, params, options=None):
Copy link
Collaborator Author

@pavelkomarov pavelkomarov Jun 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need all these shadow functions behind the front-facing ones, because you should really only define the matrices once and then call smoothing twice. As written, the matrices were getting redefined for the forward and backward passes separately, extra work. So the two levels got combined across all these methods.

#########################################
# Constant 1st, 2nd, and 3rd derivative #
#########################################
def _constant_derivative(x, P0, A, C, R, Q, forwardbackward)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So so so so much code in this module was just copy and pasted, and the structure to call things either forward or forward and backward was repeated unnecessarily, so I've put that logic in a single place.

x_hat_forward = xhat_smooth[:, 0] # first dimension is time, so slice first element at all times
dxdt_hat_forward = xhat_smooth[:, 1]

if not forwardbackward: # bound out here if not doing the same in reverse and then combining
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's very odd to me that the Kalman with RTS smoothing already works forward and backward, but then you invert the dynamics and go again and then take a weighting of the answers. Why? I thought RTS smoothing was already supposed to be optimal. Did it not seem to work at one point, and these were further hacks? I wonder if having the bugs in the RTS smoother fixed will make the hacks unnecessary.

Copy link
Collaborator Author

@pavelkomarov pavelkomarov Jun 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The answer is that a non-optimal guess to kick off the process biases the end you start on, and it takes a while for that bias to wear off. Classic RTS (even sans bugs) doesn't fully address this, but doing two of them and weighting together does.

Ideally, you could choose an initial x0,P0 that wouldn't horribly bias the process, but due to $C$ not being full rank, there is no way to choose the single optimal x0,P0. See my ponderings on #97.

x_hat = x_hat_forward*w + x_hat_backward*(1-w)
dxdt_hat = dxdt_hat_forward*w + dxdt_hat_backward*(1-w)

dxdt_hat_corrected = np.mean((dxdt_hat, dxdt_hat_forward), axis=0) # What is this line for?
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This strikes me as a hack. I don't like it.

:type params: dict {'backward': boolean}, optional

:return: a tuple consisting of:
def constant_acceleration(x, dt, params=None, options=None, r=None, q=None, forwardbackward=True):
Copy link
Collaborator Author

@pavelkomarov pavelkomarov Jun 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, these function signatures repeat a bunch of docstring and input-checking logic, but I can't simplify further without breaking backwards compatibility. I'd prefer a single constant_derivative function that takes order as a parameter.

:return: matrix:
- xhat_smooth: smoothed estimates of the full state x
:rtype: tuple -> (np.array, np.array)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring indicates here and in its topline by use of the words "to estimate the derivative" that this function should be calculating a derivative, but it's not. This begs the question: Does anybody use this, since it went unnoticed? Can I break backwards compatibility for this function alone?



###################################################################################################
# Constant Acceleration with Savitzky-Golay pre-estimate (not worth the parameter tuning trouble) #
Copy link
Collaborator Author

@pavelkomarov pavelkomarov Jun 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is true, then we should nuke this code per #95. If we ever wish to recover the experiment, we can go back to an ancient tagged commit and fish it out of the nether.

if len(x.shape) == 2:
pass
else:
x = np.reshape(x, [1, len(x)])
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not necessary to reshape 1D vectors into vertical 2D column vectors, because A @ x will do the exact same thing in either case.


xhat_fp = None
P_fp = []
P_fm = [P_fm]
Copy link
Collaborator Author

@pavelkomarov pavelkomarov Jun 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This initialization is slightly odd, because it says x0, P0 are the a priori estimate for the current time based on the previous time, rather than considering them the a posteriori estimate from last time. The difference is that last time's a posteriori estimate would be propagated forward before projection through $C$ to produce an expected measurement for combination with the first measurement, whereas an a priori estimate already associated with the current time would simply be projected through $C$.

What makes more sense when we ask the user for an initial condition? "Tell us where you think the system is, one time step before seeing any measurements" or "Tell us where you think the system is at the moment of the first measurement"? Six on one hand, half dozen on the other. I spent a good while in #97 trying to think of a good way to initialize the cycle, but any projection from measurement space to full state space requires the measurement space to have the same dimension as the state space, which just isn't our case at all for all the constant derivative stuff, where the dimension of x goes up to the $\nu^\text{th}$ derivative but the dimension of y is always 1.

In the end I decided I prefer to project our identity matrix P0 guess through $A$ and then add $Q$ before projecting through $C$ to get the expected covariance, if only because it agrees with what I've written in the paper and most explanations I've ever seen.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we end up preferring the opposite, it's easy to change _kalman_forward_filter.

:param Q:
:return:
if B is None: B = np.zeros((A.shape[0], 1))
if u is None: u = np.zeros(B.shape[1])
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

u can safely be 1D.

@pavelkomarov pavelkomarov changed the title addressing #68 for the kalman module, discovering bugs in the process addressing #68 and #69 for the kalman module, discovering bugs in the process Jun 23, 2025
@pavelkomarov pavelkomarov changed the title addressing #68 and #69 for the kalman module, discovering bugs in the process addressing #68, #69, and #71 for the kalman module, discovering bugs in the process Jun 23, 2025
elif r == None or q == None:
raise ValueError("`q` and `r` must be given.")

A = np.array([[1, dt, (dt**2)/2], # states are x, x', x"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using a more exact A here, per #96

@pavelkomarov pavelkomarov changed the title addressing #68, #69, and #71 for the kalman module, discovering bugs in the process addressing #68, #69, #71, #95, and #96 for the kalman module, discovering bugs in the process Jun 23, 2025
@pavelkomarov pavelkomarov changed the title addressing #68, #69, #71, #95, and #96 for the kalman module, discovering bugs in the process addressing #68, #69, #71, #95, and #96 for the kalman module Jun 25, 2025
@pavelkomarov
Copy link
Collaborator Author

pavelkomarov commented Jun 25, 2025

Okay, reporting in about the "correction" happening in the Kalman smooth functions, which I thought shouldn't be there and was maybe the result of a hack to compensate for an RTS smoothing bug.

Here is constant_velocity on the first two test functions with the "correction" still in my code:
Screenshot 2025-06-25 at 15 23 06

The blue line is the function, and the yellow line is its derivative.

And here is the performance if I remove the correction and just stick to weighting the forward and backward passes of RTS:
Screenshot 2025-06-25 at 15 24 33

And just for completeness, here is the result again without the correction, but when I set forwardbackward=False:
Screenshot 2025-06-25 at 15 29 56

I'll compare performance for constant_acceleration and constant_jerk as well to see if this pattern holds.

@pavelkomarov
Copy link
Collaborator Author

Here is the full plot for constant_acceleration with the "correction":
Screenshot 2025-06-25 at 15 39 24

And here is without the correction:
Screenshot 2025-06-25 at 15 37 53

Without a doubt, without is better. I'm going to remove the correction in this PR.

@pavelkomarov
Copy link
Collaborator Author

pavelkomarov commented Jun 25, 2025

This one now makes use of the changes from #102, so order of operations says that one should be reviewed and merged first. Then we can git pull origin master to this branch to rebase and exclude those changes from this review.

@pavelkomarov pavelkomarov changed the base branch from master to smooth-fd-docstrings June 26, 2025 23:57
@pavelkomarov pavelkomarov mentioned this pull request Jun 27, 2025
@pavelkomarov pavelkomarov changed the base branch from smooth-fd-docstrings to master June 27, 2025 18:25
@pavelkomarov pavelkomarov merged commit e7026bd into master Jun 27, 2025
1 check passed
This was referenced Jun 27, 2025
@pavelkomarov pavelkomarov deleted the kalman-docstrings-and-doublecheck branch June 27, 2025 21:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants