This notebook looks at a technique for simultaneous localization (finding the position of a robot) and mapping (finding the positions of any obstacles), abbreviated as SLAM. In this model, the probability distribution for the robot's trajectory \(x_{1:t}\) is represented with a set of weighted particles. Let the weight for particle \(i\) be denoted \(w^i\), and let \(W\) denote the sum of all the weights. Each particle also keeps track of the conditional distribution of obstacle locations \(m\) given \(x_1, \dotsc x_t\). Even though each particle \(i\) should be thought of as describing a full potential trajectory \(x_1^i, \dotsc x_t^i\), we will only need to store the position at the most recent time \(x_t^i\).
using LinearAlgebra, StaticArrays, LogExpFunctions, Distributions, ArraysOfArrays, FillArrays, Plots
const Dist = Tuple{SVector{2, Float64}, SMatrix{2,2, Float64}}
Tuple{SVector{2, Float64}, SMatrix{2, 2, Float64}}
sample(d::Dist) = rand(Distributions.MvNormal(d[1], d[2]))
sample (generic function with 1 method)
struct Particle
weight::Float64 # stored in log scale
x::SVector{2, Float64}
m::Vector{Dist}
end
const k = 6; # Number of obstacles
function init_particles(n)
d = (zeros(SVector{2}), SMatrix{2,2}(10.0I))
p = Particle(0.0, zeros(SVector{2}), fill(d, k))
fill(p, n)
end;
const n = 100; # Number of particles
particles = init_particles(n);
The particles \(x^i\) at time \(t\) will always be samples from a proposal distribution \(q_t(x^i)\). The weights \(w^i\) will be importance ratios of the form \(C \pi_t(x^i)/q_t(x^i)\), where \(\pi_t(x^i)\) is our target distribution and \(C\) is a constant. Given these particles, we can approximate \(E_{X_t \sim \pi_t} f(X_t)\) for any function \(f\) by computing
In our case, the target distribution \(\pi_{t+1}(x^i)\) at time \(t+1\) will be the conditional distribution \(p(x^i_{1:t+1} | z_{1:t+1})\), and the proposal distribution \(q_{t+1}(x^i) = \pi_t(x^i_{1:t})p(x_{t+1} | x_t, z_{1:t+1})\). By Bayes' rule
Similarly,
This means the ratio simplifies to \(p(z_{t+1} | x_t, z_{1:t})\).
At each step \(t\) in time, our algorithm will do the following: 1. Resample the particles in proportion to their importance weights. This effectively turns our samples from \(q_t\) into samples from \(\pi_t\). 2. Extend each particle, turning the samples from \(\pi_t\) into samples from \(q_{t+1}\). This requires sampling from \(p(x_{t+1}^i | x_t^i, z_{1:t})\), computing the importance weight \(p(z_{t+1} | x_T, z_{1:t})\), and updating the associated distribution \(p(m | x_{1:t}, z_{1:t})\).
At \(t=0\), the proposal and target distributions are the same so we don't need to resample.
function step(particles::Vector{Particle}, z)
new_particles = propose.(particles, Ref(z))
ix = resample(softmax([p.weight for p in new_particles]))
resampled = new_particles[ix]
Particle[Particle(0.0, p.x, p.m) for p in resampled]
end;
Resampling
If w
is our vector of weights, we want to sample \(k\) elements of w
in proportion to their values. The most obvious way to do this is with inverse transform sampling. Let b = cumsum(w)
. For each random sample, we can pick a uniform \(u_i\) between \(0\) and \(W\), which corresponds to searchsortedfirst(b, u_i)
.
To lower the variance of this approach, we can use stratified sampling. Instead of letting each \(u_i\) come from the full interval \([0,W]\), we can restrict each to a separate interval of size \(W/n\).
An even lower variance approach is to sample a single uniform number in \([0,W]\) and shift it to each of the stratified intervals. This is called systematic resampling.
function resample(w::Vector{Float64})
u = rand()
ui = [(u + i-1) / length(w) for i in 1:length(w)]
bins = cumsum(w)
[searchsortedfirst(bins, i) for i in ui]
end;
Proposing
We will assume a simple dynamics model where \(p(x_{t+1} | x_t) = \mathcal{N}(x_t + \delta, \Sigma_A)\). For the likelihood, we observe noisy distances to obstacles, so that an observation at time \(t\) is distributed \(p(z_t | x_t, m) = \mathcal{N}(m - x_t, \Sigma_B)\).
To find the posterior \(p(x_{t+1} | x_t, z_{t+1})\), it will be easier to work with the general case where \(A \sim \mathcal{N}(\mu_A, \Sigma_A)\), \(B \sim \mathcal{N}(\mu_B, \Sigma_B)\), and \(Z \sim \mathcal{N}(B - A, \Sigma_Z)\). Marginalizing out \(B\) from the definition of \(Z\) gives \(Z \sim \mathcal{N}(\mu_B - A, \Sigma_B + \Sigma_Z)\). Let \(\Sigma_B + \Sigma_Z = \Sigma_{BZ}\) and \(A \circ B \coloneqq \text{Trace}(A^TB)\). By Bayes' rule:
This shows that the posterior is Gaussian with natural parameters \((\Sigma_A^{-1} \mu_A + \Sigma_{BZ}^{-1} (\mu_B - Z),\, \Sigma_A^{-1} + \Sigma_{BZ}^{-1})\).
For FastSLAM, \(\mu_A = x_t + \delta\), so \(p(x_{t+1} | x_t, z_{t+1})\) has natural parameters
Similarly, to update the obstacle distribution upon seeing \(x_{t+1}\) and \(z_{t+1}\):
For the importance weight, note that \(p(z_{t+1} | X_t, z_{1:t}) = \mathcal{N}(\mu_B - (x_t + \delta), \Sigma_B + \Sigma_Z)\).
begin
ΣA = Eye(2,2)
ΛA = inv(ΣA)
ΣZ = (1/6) * Eye(2,2)
ΛZ = inv(ΣZ)
δ = SVector(2.,2.)
end;
function post_msg_x(m, z)
ΣBZ = m[2] + ΣZ
(ΣBZ \ (m[1] - z), inv(ΣBZ))
end;
post_msg_m(x, z) = (ΛZ \ (z - x), ΛZ)
post_msg_m (generic function with 1 method)
function propose(p, z)
post_x_msgs = sum(post_msg_x.(p.m, z))
xmean = (p.x + δ)
prior_x_msg = (ΛA * xmean, ΛA)
xpost = meanform(prior_x_msg .+ post_x_msgs)
new_x = sample(xpost)
w = sum(importance.(p.m, z, Ref(xmean)))
post_m_msgs = post_msg_m.(Ref(new_x), z)
mpost = meanform.(stdform.(p.m) .+ post_m_msgs)
Particle(w, new_x, mpost)
end;
function importance(m, z, xmean)
logpdf(Distributions.MvNormal(m[1] - xmean, m[2] + ΣZ), z)
end
importance (generic function with 1 method)
function stdform((mu, var))
(var \ mu, inv(var))
end
stdform (generic function with 1 method)
function meanform((prec_mu, prec))
(prec \ prec_mu, inv(prec))
end
meanform (generic function with 1 method)
Fake Data
Here we generate the unknown, true trajectory that the robot takes by following our dynamics model.
begin
RΣA = sqrt(ΣA)
true_xs = cumsum(δ .+ RΣA * randn(2, 100), dims=2)
end;
We'll also set up the true but unknown obstacle locations.
true_ms = 50 * randn(2, k);
begin
plot(true_xs[1, :], true_xs[2, :], label="robot path")
scatter!(true_ms[1,:], true_ms[2,:], label="obstacles")
end
The displacement observations the robot sees along its trajectory look like this:
begin
RΣZ = sqrt(ΣZ)
true_zs = [(true_ms .- x) + RΣZ * randn(2, k) for x in nestedview(true_xs)]
z = flatview(ArrayOfSimilarArrays(true_zs));
end;
To show the direction of time, lines will get fainter with time.
timealpha = reverse(0.1 .+ ((1:size(z, 3))./size(z, 3)));
begin
plt = plot(z[1,1,:], z[2,1,:], label="z1", alpha=timealpha)
for i in 2:k
plot!(z[1,i,:], z[2,i,:], label="z$i", alpha=timealpha)
end
plt
end
Simulating the Algorithm
Base.:+(a::Tuple, b::Tuple) = a .+ b # So we can add distribution parameters
sz = map(SVector{2}, eachslice(z; dims=(3,2))); # Rewrites z as a matrix of SVec
steps = accumulate(step, eachslice(sz;dims=1); init=particles);
predicted = SVector{2, Float64}[mean(p-> p.x, s) for s in steps];
flat_pred = flatview(ArrayOfSimilarArrays(predicted));
begin
plot(true_xs[1, :], true_xs[2, :], label="robot path")
plot!(flat_pred[1,:], flat_pred[2,:], label="predictions", alpha=timealpha)
end
Even though the robot's trajectory is noisy, the observations allow us to track it quite accurately.