Muxiao Liu. login
~ $ open kalman-filter.md
WORKSHOP · NEURAL DECODING

Using A Kalman Filter for Neural Decoding

planted aug 18, 2026 · v1.0.0
v1.0.0 aug 31 planted
~/neural-decoding/kalman-filter.mdfirst half: momentum

The task is to decode the movement (x and y position + x and y velocity) of a monkey moving a cursor based on spiking activities of some neurons.

The principle we follow is that precision adds. When we have two independent and noisy measurements of a truth, we can add them with a certain weight and end up with a measurement that is more precise than both raw measurements. This is called a Kalman filter, and it looks mathematically identical to the EMA that I used to do velocity smoothing for a noisy velocity measurement in the inverted pendulum bot.

z^=Kz1+(1K)z2\hat{z}=Kz_1+(1-K)z_2

Let’s try to prove that z^\hat{z} has a higher precision than both z1z_1 and z2z_2 - i.e. the variance of z^\hat{z} is lower than both of them.

Let μ\mu be E[z1]=E[z2]E[z_1]=E[z_2] (Since both are noisy measurements around the same ground truth, no bias).

Var(Kz1+(1K)z2)=Var(Kz1)+Var((1K)z2)independent, so variances add=K2Var(z1)+(1K)2Var(z2)a constant comes out squared=K2σ12+(1K)2σ22\begin{aligned} \mathrm{Var}\big(Kz_1+(1-K)z_2\big) &= \mathrm{Var}(Kz_1)+\mathrm{Var}\big((1-K)z_2\big) &&\text{independent, so variances add}\\ &= K^2\,\mathrm{Var}(z_1)+(1-K)^2\,\mathrm{Var}(z_2) &&\text{a constant comes out squared}\\ &= K^2\sigma_1^2+(1-K)^2\sigma_2^2 \end{aligned}

Differentiate with respect to KK and set to zero to minimize the variance:

ddK[K2σ12+(1K)2σ22]=2Kσ122(1K)σ222Kσ122(1K)σ22=0K(σ12+σ22)=σ22K=σ22σ12+σ22\begin{aligned} \frac{d}{dK}\Big[K^2\sigma_1^2+(1-K)^2\sigma_2^2\Big] &= 2K\sigma_1^2-2(1-K)\sigma_2^2 \\ 2K\sigma_1^2-2(1-K)\sigma_2^2 &= 0 \\ K\big(\sigma_1^2+\sigma_2^2\big) &= \sigma_2^2 \\ K &= \frac{\sigma_2^2}{\sigma_1^2+\sigma_2^2} \end{aligned}

What does this remind you of? E&M was only a semester ago for me so this jumps out as a parallel circuit’s resistance.

Rcombined=R1R2R1+R2R_{combined}=\frac{R_1R_2}{R_1+R_2}

Then, naturally, as the combined resistance is lower for parallel circuits than each resistor’s, so does the variance of the blended measurement. There is a shared mechanism behind both. Circuits also minimize something - power.

P=I2R=I12R1+I22R2=I12R1+(II1)2R2I1=IR2R1+R2P=I^2R=I_1^2R_1+I_2^2R_2=I_1^2R_1+(I-I_1)^2R_2 \\ I_1 = I\frac{R_2}{R_1+R_2}

And analogous to conductance, the inverses of their variance, as we mentioned, adds up to the combined precision (of z^\hat{z}):

1σcombined2=1σ12+1σ22\frac{1}{\sigma_{combined}^2}=\frac{1}{\sigma_1^2}+\frac{1}{\sigma_2^2}

We have proven that precision adds when we blend two independent and noisy raw measurements with the optimal weight σ22σ12+σ22\frac{\sigma_2^2}{\sigma_1^2+\sigma_2^2}, their precisions add. We end up with a better measurement than both. Yet, in the case of neural decoding, it looks like we only have one raw measurement - the spikes. So here comes the cool trick:

We get to use the prediction of the previous timestep as an independent input.

x^=Kz+(1K)x\hat{x}=Kz+(1-K)x

This is similar to a Wiener filter, where we directly use the previous nn bins to inform the current one. The reason we can do this is because motion is continuous, and momentum determines a large portion of the following movement.

First Half: Momentum

Let’s assume a linear relationship between the previous state and the current state (why?).

x^t=ax^t1\hat{x}_t^- = a\hat{x}_{t-1}

But it is not always linear. There is noise, and the monkey changes its movement all the time. Let’s call that error that we cannot account for ww with variance qq.

xt=axt1+wx_{t} = ax_{t-1}+w \\ Var(w)=q\mathrm{Var}(w) = q

ww and qq are properties of the dataset (with regard to aa) and are fixed. They describe how un-linear (if you get what I mean) the true data is. Furthermore, since when we decode, we don’t have the ground truth xx but only the previous prediction x^\hat{x}. That premise could be wrong too. At each timestep, the prediction we make has a varying error, let’s call its variance PP. It evolves over time by:

P=a2P+qP^-=a^2P+q

qq comes from the un-linear noise. and a2a^2 is how the previous error PP scale the current one.

Second Half: Spikes

This is not really a half, more like an eighth. The spikes is more straightforward than momentum, since there is no error carrying forward. Let’s just assume the spike give us a similarly noisy and independent measurement of the state zz with variance PzP_z.

The Magic Blender

Now, as we proved earlier, we can now find the optimal KK to blend the two halves.

K=PP+Pzx^=Kz+(1K)x^P=11P+1Pz\begin{aligned} K &= \frac{P^-}{P^-+P_z} \\ \hat{x} &= Kz + (1-K)\hat{x}^- \\ P &= \frac{1}{\frac{1}{P^-}+\frac{1}{P_z}} \end{aligned}

At this point, no one can keep track of the symbols anymore, so here is how the process goes:

  1. We start with nothing but the first spikes. Translate them to states. Gets zz, and using it as our first estimated states x^\hat{x}. P=PzP=P_z.
  2. Moving on to the next timestep. We now have the two halves. Before we receive the second spikes, evolve the previous prediction first by our momentum guess: x^=ax^,P=a2P+q\hat{x}^- = a\hat{x}, P^-=a^2P+q.
  3. Receiving the spikes zz, we now do the magical blend: x^=Kz+(1K)x^,P=11P+1Pz\hat{x} = Kz + (1-K)\hat{x}^-,P = \frac{1}{\frac{1}{P^-}+\frac{1}{P_z}}.
  4. Go back to step 2 unless no more timestep left.

The superscript ()(\quad)^- denotes step 2, the intermediate estimate and error solely from momentum, before receiving the next spikes.

The Matrix

Welcome to the real world, Neo.

In reality, we have not 1, but 4 numbers representing the state of a cursor. its x-position, y-position, x-velocity, and y-velocity. We also don’t automatically get the spike’s prediction for the states. All we get is a string of processed data that tells us in each timestep, whether a neuron spiked or not. In my dataset, there are 223 such neurons.

Let us try to map the scalar versions of our equations over.

x^=Ax^\hat{x}^- = A\hat{x}

Here, x^\hat{x}^- and x^\hat{x} are vectors of height 4. and AA is a 4×44 \times 4 matrix. Let ww be the error that AA cannot account for, and QQ be its covariance - a 4 ×4\times 4 matrix.

Before we look at what covariance is. Let’s appreciate the fact that we have introduced complexity on our motion model. We used to have a single state in the scalar version - velocity. Now we have four. So each state becomes a linear combination of all the previous states: a1px+a2py+a3vx+a4vya_1p_x+a_2p_y+a_3v_x+a_4v_y. E.g., the estimated x-position may be p^=px+Δtvx\hat{p}^-=p_x+\Delta t v_x.

If we try to again find how the variance of their error behave, we are adding the error of the four states. If the states were independent, we can simply add their variances like we did before. However, the four states are clearly not independent. That is why we need to look at covariance.

Covariance

Recall how variance is the expected value of the squared difference between a random variable and its mean. The reason we squared it was to keep it positive, while keeping the algebra easy (unlike absolute value).

How do we compute the variance of a sum of two random variables? If they are independent, we know to just add their repective variances. What if they are not independent? Let’s expand and see.

Let uu be XE[X]X-E[X] and vv be YE[Y]Y-E[Y].

Var(X+Y)=E[(u+v)2]=E[u2]+E[v2]+2E[uv]=E[(XE[X])2]+E[(YE[Y])2]+2E[(XE[X])(YE[Y])]\begin{aligned} \mathrm{Var}(X+Y)&=E[(u+v)^2] \\ &=E[u^2]+E[v^2]+2E[uv] \\ &=E[(X-E[X])^2]+E[(Y-E[Y])^2]+2E[(X-E[X])(Y-E[Y])] \end{aligned}

When XX and YY are independent, E[(XE[X])(YE[Y])]E[(X-E[X])(Y-E[Y])] is 00. Since E[XE[X]]E[X-E[X]] and E[YE[Y]]E[Y-E[Y]] are both 00. When they are not independent, we get this term 2E[(XE[X])(YE[Y])]2E[(X-E[X])(Y-E[Y])] to adjust.

Cov(X,Y)=E[(XE[X])(YE[Y])]\mathrm{Cov}(X,Y)=E[(X-E[X])(Y-E[Y])]

Covariance is about how two random variables tend to vary together. Consider two random variables, XX and YY. Let them only take values of either 11 or 1-1. If we sum them, there are 4 possible scenarios:

X=1X=1X=1X=-1
Y=1Y=120
Y=1Y=-10-2

When the covariance is high (and positive), the variables tend to take similar values (in our case, the same value), the sum of the two variables have a large variance. Otherwise, they tend to take different values, the positive and negative cancels out and we tend to have a small variance (or in this case, 0).

Heading back into our quest of finding the variance of x^\hat{x}^-. With the added covariance to the equation, can we still elegantly represent the uncertainty of the prediction the way P=a2P+qP^-=a^2P+q did in the scalar version?

Note how the covariance of a single variable is the variance of the variable. We can absolutely use a matrix-vector multiplication to express how the variance of x^\hat{x} itself propagates through AA. We may then add a term QQ, corresponding to the scalar qq, for the variance of the error. AA also gets squared through covariance operator.

Cov(Ax^)=ACov(x^)A\mathrm{Cov}(A\hat{x})=A\mathrm{Cov}(\hat{x})A^\top P=APA+QP^-=APA^\top+Q

Looking back at the scalar version of the Kalman filter blend, we try to do something similar for the matrices:

x^=Kz+(1K)x^\hat{x}=Kz+(1-K)\hat{x}^-

Yet, remember in the beginning we mentioned how we don’t automatically get the spike’s prediction for the states. We only get the spikes. The intuition would be to decode the spikes directly based on their directional preferences. While that fits our scalar version better, it introduces some complications. If we fitted the decoder in the training block of the dataset, we end up with a decoder that rely partly on the spikes, and partly with knowledge about momentum - that breaks our rules that the two measurements have to be independent. We need to instead translate the states back into spikes - encoding it.

z=Hx+ez=Hx+e

Note that zz is now the spikes we observe. xx is the true states, and HH is a matrix we can fit. For each neuron, we can fit its cosine tuning curve against the training block’s data. We then can calculate the expected spikes for each direction for each neuron.

Now, last step: KK.

In the scalar version we had:

K=PP+PzK= \frac{P^-}{P^-+P_z}

We already have PP^-, but what is PzP_z now? It was the uncertainty in the spikes’ prediction of the state earlier. We assumed we knew it, but we don’t. We only have the spikes now. Of course, neurons have noise too. Their spikes may scatter around the expected value (Poisson distribution). Let us call the covariance matrix of each neuron’s scattering RR.

Let’s look at one neuron and one state for now, say y-velocity. We model a neuron as direction-selective. If a neuron is sensitive to upward movements, say it fires hh more spikes per unit of upward velocity, when we translate 3 unit of upward velocity to spikes of this neuron, we would expect 3h3h spikes above its baseline. z=hx+noisez=hx+\text{noise}. Where zz, hh and xx are all scalars.

Var(noise)=r\mathrm{Var}(\text{noise})=r x^=zh\hat{x}=\frac{z}{h} Var(x^)=(1h)2Var(z)\mathrm{Var}(\hat{x})=\left(\frac{1}{h}\right)^2\mathrm{Var}(z)

zz only varies because of the noise, since hxhx is fixed.

Var(x^)=rh2\mathrm{Var}(\hat{x})=\frac{r}{h^2}

That all looks innocent and simple, but we have 4 states and 223 neurons, not 1 and 1. For us to add PP^- and the spikes’ uncertainty, they need to mean uncertainty in the same space. PP^- is already in the state space with 4 dimensions. We need the other uncertainty to also be in the state space. Let’s go from the ground up again. We have the observed spikes zz, and we want to translate it to state space and find the variance of the error. With noise in each neuron’s spikes, each neuron disagrees with each other in what the state is. Let’s try to use the good old derivative and see if we can minimize the squared error to get a good estimate instead.

The loss LL has to be calculated in spike space, since we do not get the true state during decoding. They also have to be weighted by the precision of each neuron 1ri\frac{1}{r_i}.

L(x)=i=1223(zihix)2riL(x)=\sum_{i=1}^{223}\frac{(z_i-h_ix)^2}{r_i}

The xx inside LL is a candidate state I am searching over - not the true state, and not the answer. It is the dial I turn. The one value of it that makes LL smallest is the estimate, and I write that one x^z\hat{x}_z: the state the spikes vote for.

dLdx=i=12232hi(zihix)ri=0xi=1223hi2ri=i=1223hizirix=i=1223hizirii=1223hi2ri\begin{aligned} \frac{dL}{dx}=\sum_{i=1}^{223}\frac{-2h_i(z_i-h_ix)}{r_i}&=0 \\ x\sum_{i=1}^{223}\frac{h_i^2}{r_i} &= \sum_{i=1}^{223}\frac{h_iz_i}{r_i} \\ x &= \frac{\sum_{i=1}^{223}\frac{h_iz_i}{r_i}}{\sum_{i=1}^{223}\frac{h_i^2}{r_i}} \end{aligned}

How absolutely ugly!

Note that everything here are scalars, this is for a single state. Let’s make it 4.

L(x)=i=1223(zij=14hijxj)2riL(x)=\sum_{i=1}^{223}\frac{(z_i-\sum_{j=1}^4h_{ij}x_j)^2}{r_i} Lxk=i=12232hik(zij=14hijxj)ri=0i=1223hikj=14hijxjri=i=1223hikziri\begin{aligned} \frac{\partial L}{\partial x_k}=\sum_{i=1}^{223}\frac{-2h_{ik}(z_i-\sum_{j=1}^4h_{ij}x_j)}{r_i}&=0 \\ \sum_{i=1}^{223}\frac{h_{ik}\sum_{j=1}^4h_{ij}x_j}{r_i}&=\sum_{i=1}^{223}\frac{h_{ik}z_i}{r_i} \end{aligned}

This holds for each k=1,2,3,4k=1, 2, 3, 4.

This still looks complicated, but something looks dangerously close to how Axler defines a matrix. Let’s try the right hand side first.

i=1223hikziri=i=1223hikziri=(HR1z)k\begin{aligned} \sum_{i=1}^{223}\frac{h_{ik}z_i}{r_i}&=\sum_{i=1}^{223}h_{ik}\frac{z_i}{r_i} \\ &= (H^{\top}R^{-1}z)_k \end{aligned}

ziri=(R1z)i\frac{z_i}{r_i}=(R^{-1}z)_i requires us to assume RR is diagonal (which is probably not true, but a trade-off I am taking).

i=1223hikj=14hijxjri=i=1223hik(Hx)iri=(HR1Hx)k\begin{aligned} \sum_{i=1}^{223}\frac{h_{ik}\sum_{j=1}^4h_{ij}x_j}{r_i}&=\sum_{i=1}^{223}\frac{h_{ik}(Hx)_i}{r_i} \\ &= (H^\top R^{-1}Hx)_k \end{aligned}

So nice.

(HR1z)k=(HR1Hx)kHR1z=HR1Hxx^z=(HR1H)1HR1z\begin{aligned} (H^{\top}R^{-1}z)_k &= (H^\top R^{-1}Hx)_k \\ H^{\top}R^{-1}z &= H^\top R^{-1}Hx \\ \hat{x}_z &= (H^\top R^{-1}H)^{-1}H^{\top}R^{-1}z \end{aligned}

x^z\hat{x}_z is the state that would minimize the loss function LL. It is parallel to x^\hat{x}^-, which is the momentum half’s guess of the state.

We are one last step from getting KK! We still need the covariance of x^z\hat{x}_z as well.

Cov[(HR1H)1HR1z]=(HR1H)1HR1Cov(z)[(HR1H)1HR1]=(HR1H)1HR1R[(HR1H)1HR1]\begin{aligned} \mathrm{Cov}\left[(H^\top R^{-1}H)^{-1}H^{\top}R^{-1}z \right]&=(H^\top R^{-1}H)^{-1}H^{\top}R^{-1}\mathrm{Cov}(z)[(H^\top R^{-1}H)^{-1}H^{\top}R^{-1}]^\top \\ &= (H^\top R^{-1}H)^{-1}H^{\top}R^{-1}R[(H^\top R^{-1}H)^{-1}H^{\top}R^{-1}]^\top \end{aligned}

This is just Cov(Ax)=ACov(x)A\mathrm{Cov}(Ax)=A \mathrm{Cov}(x)A^\top.

Notice RR is the covariance and therefore symmetric (covariance between aa and bb is equivalent to covariance between bb and aa). Its transpose equals itself.

Cov(x^z)=(HR1H)1HR1R[(HR1H)1HR1]=(HR1H)1HR1R(R1H(HR1H)1)=(HR1H)1HR1H(HR1H)1=(HR1H)1\begin{aligned} \mathrm{Cov}(\hat{x}_z) &= (H^\top R^{-1}H)^{-1}H^{\top}R^{-1}R[(H^\top R^{-1}H)^{-1}H^{\top}R^{-1}]^\top \\ & = (H^\top R^{-1}H)^{-1}H^{\top}R^{-1}R(R ^{-1}H(H^\top R^{-1}H) ^{-1}) \\ &= (H^\top R^{-1}H)^{-1}H^{\top}R^{-1}H(H^\top R^{-1}H) ^{-1} \\ &= (H^\top R^{-1}H) ^{-1} \end{aligned}

So incredibly nice. This matches our 1-neuron-1-state version, where we claimed Var(x^)=rh2\mathrm{Var}(\hat{x})=\frac{r}{h^2}. And this is the PzP_z that the scalar filter simply assumed we had - we have finally built it, out of HH and RR.

We are now ready for KK. Let’s look at the scalar version and guess an analogy:

K=P(Pz+P)1K = P^-(P_z+P^-)^{-1} x^=x^+K(x^zx^)=Kx^z+(IK)x^\begin{aligned} \hat{x} &= \hat{x}^- + K(\hat{x}_z - \hat{x} ^-) \\ &= K\hat{x}_z+(I-K)\hat{x}^- \end{aligned}

Can we really do this though? I want to look at the total variance after the blend and see if it follows our theme of how precision adds.

Cov(x^)=KPzK+(IK)P(IK)\begin{aligned} \mathrm{Cov}(\hat{x}) &= KP_zK^\top + (I-K)P^-(I-K)^\top \\ \end{aligned}

Let T=(Pz+P)T=(P_z+P^-) for clarity.

IK=IPT1=TT1PT1=(TP)T1=PzT1\begin{aligned} I-K &= I-P^-T^{-1} \\ &= TT^{-1}-P^-T^{-1} \\ &= (T-P^-)T^{-1} \\ &= P_zT^{-1} \end{aligned}

Again, PP^- and PzP_z are covariances and therefore are symmetric. TT is too, as the sum of two symmetric matrices.

(PT1)PzT1P+PzT1PT1Pz=[(TPz)T1]PzT1(TPz)+PzT1(TPz)T1Pz=(IPzT1)Pz[I(T1Pz)]+PzT1(IPzT1)Pz=(PzPzT1Pz)[I(T1Pz)]+PzT1(PzPzT1Pz)=PzPz(T1Pz)PzT1Pz+PzT1PzT1Pz+PzT1PzPzT1PzT1Pz=PzPz(T1Pz)=Pz(IT1Pz)=Pz(T1TT1Pz)=Pz[T1(TPz)]=PzT1P=Pz(Pz+P)1P\begin{aligned} (P^-T^{-1})P_zT^{-1}P^- + P_zT^{-1}P^-T^{-1}P_z &= [(T-P_z)T^{-1}]P_zT^{-1}(T-P_z) + P_zT^{-1}(T-P_z)T^{-1}P_z \\ &= (I-P_zT^{-1})P_z[I-(T^{-1}P_z)] + P_zT^{-1}(I-P_zT^{-1})P_z \\ &= (P_z-P_zT^{-1}P_z)[I-(T^{-1}P_z)] + P_zT^{-1}(P_z-P_zT^{-1}P_z) \\ &= P_z-P_z(T^{-1}P_z)-P_zT^{-1}P_z+P_zT^{-1}P_zT^{-1}P_z + P_zT^{-1}P_z-P_zT^{-1}P_zT^{-1}P_z \\ &= P_z-P_z(T^{-1}P_z) \\ &= P_z(I-T^{-1}P_z) \\ &= P_z(T^{-1}T-T^{-1}P_z) \\ &= P_z[T^{-1}(T-P_z)] \\ &= P_zT^{-1}P^- \\ &= P_z(P_z+P^-)^{-1}P^- \\ \end{aligned}

Let’s call P=Cov(x^)P=\mathrm{Cov}(\hat{x}), and invert it to get precision of x^\hat{x} instead of covariance.

P1=(Pz(Pz+P)1P)1=(P)1(Pz+P)Pz1=[(P)1Pz+(P)1P]Pz1=(P)1+Pz1\begin{aligned} P^{-1} &= (P_z(P_z+P^-)^{-1}P^-)^{-1} \\ &= (P^{-})^{-1}(P_z+P^-)P_z^{-1} \\ &= [(P^{-})^{-1}P_z+(P^{-})^{-1}P^-]P_z^{-1} \\ &= (P^{-})^{-1}+P_z^{-1} \\ \end{aligned}

Woah! What do we have here? P1=(P)1+Pz1P^{-1}=(P^{-})^{-1}+P_z^{-1}. Precision adds! We found the analogous way to make KK reproduces precision adding. That closes the loop. We now have a working Kalman filter on the full dataset :)

I will add experimental results to my dataset at a later date.

~ $ cat comments  # 3 — muxiao reads these
SOMEONE
#1aug 29reply
hiiiiii
SOMEONE
#2aug 29reply
hiiii 明德体能/云台/大树评测官/斑斑/广式表情包Tony28.5
SOMEONE
#3aug 29reply
Prof.Tony
SOMEONE· aug 29
It should be Mr.Tony
SOMEONE· aug 29
Mr.Liu
rm
~ $