Particle Filter

Exercise 4-4 from Probabilistic Robotics

This notebook addresses a particle filter (PF) from exercise 4-4 (page 116) in Probabilistic Robotics by Thrun, Bergard, and Fox.

In [1]:
# Setup notebook for calculations and plotting

# Plotting
# Comment following line (%matplotlib inline) for interactive inline plots
# %matplotlib inline
import matplotlib

# Uncomment following line for interactive inline plots
matplotlib.use('nbAgg')

from mpl_toolkits.mplot3d import axes3d
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.cm as cm

# Numerical simulations
import math
import numpy as np
import scipy.stats as stats
import pandas as pd
# import collections

1. Exercise 4-4

  1. Implement Exercise 1 using particle filters instead of histograms, and plot and discuss the results.

See notebook 07 for the results of exercise 4-1. Exercise 4-1 is an extension of exercise 3-1 in notebook 03, which uses a Kalman filter (KF).

Description of Exercise 3-1

Movement Model

In this and the following exercise, you are asked to design a Kalman filter for a simple dynamical system: a car with linear dynamics moving in a linear environment. Assume $\Delta T = 1$ for simplicity. The position of the car at time $t$ is given by $x_t$. Its velocity is $\dot x_t$, and its acceleration is $\ddot x_t$.Suppose the acceleration is set randomly at each point in time, according to a Gaussian with zero mean and covariance $\sigma^2 = 1$.


$$ \tag{1} \dot x_t = \dot x_{t-1} + \ddot x_t $$

$$ \tag{2} x_t = x_{t-1} + \dot x_{t-1} + \frac{\ddot x_t}{2} $$

Equations (1) and (2) show how we can generate the state at time $t$ from the state at time $t-1$, given an acceleration $\ddot x_t$.

Measurement Model

We will now add measurements to our Kalman filter. Suppose at time $t$, we can receive a noisy observation of $x$. In expectation, our sensor measures the true location. However this measurement is corrupted by Gaussian noise with covariance $\sigma^2 = 10$.


Particle Filter Overview

  1. Particle Filter Algorithm ($X_{t-1}, u_t, z_t$):
  2. $\quad \overline{X_t} = X_t = \emptyset$
  3. $\quad$for $m = 1$ to $M$ do
  4. $\qquad$sample $x_t^{[m]} \sim p(x_t \mid u_t, x_{t-1}^{[m]})$
  5. $\qquad w_t^{[m]} = p(z_t \mid x_t^{[m]})$
  6. $\qquad \overline X_t = \overline X_t + \langle x_t^{[m]}, w_t^{[m]}\rangle$
  7. $\quad$endfor
  8. $\quad$for $m = 1$ to $M$ do
  9. $\qquad$draw $i$ with probability $\propto w_t^{[i]}$
  10. $\qquad$add $x_t^{[i]}$ to $X_i$
  11. $\quad$endfor
  12. $\quad$return $X_t$

Particle Set

Our particle set $X$ will consist of 250 particles.

In [2]:
M = 1000 # Number of particles

The class PSet uses a numpy structured array to represent a particle set. It also contains array columns for weights and an empirical cumulative distribution function that will be used for the measurement update portion of the PF algorithm.

In [3]:
class PSet(object):
    
    def __init__(self, num_particles):
        """Initializes a PSet object with all particles set to (0, 0)"""
        self.num_particles = num_particles
        self.ptcs = np.zeros(num_particles, 
                                   dtype=[("pos", np.float16),
                                          ("vel", np.float16),
                                          ("weights", np.float32),
                                          ("cdf", np.float32)])
        self.belief = False
        self.measurement = None
        self.posterior = False
    
    
    def __getitem__(self, key):
        return self.ptcs[key]
    
    
    def __setitem__(self, key, value):
        self.ptcs[key] = value
        
        
    @property
    def shape(self):
        return self.ptcs.shape
    
    
    def set_cdf(self):
        """Populates the 'cdf' column, based on particle weights.
        """
        weight_sum = 0
        for row in self.ptcs:
            weight_sum = row["weights"] + weight_sum
            row["cdf"] = weight_sum
            
            
    def resample(self):
        """Returns new Posterior PSet resulting from PF resampling step
        """
        sum_weights = np.sum(self["weights"])
        rand_samples = np.sort(np.random.random_sample(size=M) * sum_weights)

        num_ptcl = self.num_particles
        posterior = PSet(num_ptcl)

        post_idx = 0
        bel_idx = 0

        while bel_idx < num_ptcl:
            while ((post_idx < num_ptcl and bel_idx < num_ptcl) and
                   rand_samples[post_idx] < self["cdf"][bel_idx]):
                posterior.ptcs["pos"][post_idx] = self["pos"][bel_idx]
                posterior.ptcs["vel"][post_idx] = self["vel"][bel_idx]
                post_idx = post_idx + 1
            bel_idx = bel_idx + 1
        posterior.posterior = True
        posterior.belief = False
        return posterior
            
    def clear_weights(self):
        """Sets weights and CDF to zero."""
        self.ptcs["weights"] = 0
        self.ptcs["cdf"] = 0
        
    def covariance(self):
        """Calculates the coviance matrix of the particle set."""
        return np.cov(self.ptcs["pos"], self.ptcs["vel"])
    
    def mean(self):
        """Returns the means for the particle set"""
        return (np.mean(self["pos"]), np.mean(self["vel"]))
    
    def scatter_plot(self, title, lims=None):
        fig = plt.figure()
        plt.plot(self["pos"], self["vel"], 'b.')
        plt.title(title)
        plt.xlabel("Position")
        plt.ylabel("Velocity")
        if lims is not None:
            plt.xlim(lims[0])
            plt.ylim(lims[1])
        plt.grid()
        plt.show()
        plt.close()
        
    def contour_plot(self, title, lims=None):
        hist, pos_edges, vel_edges = np.histogram2d(self.ptcs["pos"],
                                                    self.ptcs["vel"])
        pos_ctrs = pos_edges[0:-1] + (pos_edges[1] - pos_edges[0])/2
        vel_ctrs = vel_edges[0:-1] + (vel_edges[1] - vel_edges[0])/2
        pos_mesh, vel_mesh = np.meshgrid(pos_ctrs, vel_ctrs)
        
        contour_fig = plt.figure()
        ax = contour_fig.add_subplot(111)
        ax.contourf(pos_mesh, vel_mesh, hist, cmap=cm.PuBuGn)
        plt.title(title)
        plt.xlabel("Position")
        plt.ylabel("Velocity")
        if lims is not None:
            plt.xlim(lims[0])
            plt.ylim(lims[1])
        plt.grid()
        plt.show()
        plt.close()

Particle Set at Time 0

At time $t = 0$ we know the state of the robot precisely. It is at position 0 and velocity 0. The probability distribution looks like this:

$$ \tag{1} p(\vec x_0) = \left\lgroup \begin{matrix}1: & x = 0 \: \mathsf{and} \: \dot x = 0 \cr 0: & x \neq 0 \: \mathsf{or} \: \dot x \neq 0 \end{matrix} \right\rgroup $$

No matter how many particles we draw from this distribution, every particle will be (0, 0). We can create such a particle set simply by initializing our PSet class with the number of particles. All particles in the PSet class are initially (0, 0).

In [4]:
pset0 = PSet(M)

State Transition

The state transition portion of the generic Bayes filter algorithm is addressed by lines 3 and 4 in the particle filter algorithm. Lines 3 and 4 require us to determine the subsequent robot state (position and velocity) at time $t + 1$ for every single particle in the set at time $t$. The equations for this update (from notebook 03) are:

$$ \tag{2} \dot x_t = \dot x_{t-1} + \ddot x_t $$

$$ \tag{3} x_t = x_{t-1} + \dot x_{t-1} + \frac{\ddot x_t}{2} $$

The next_step function implements these equations to return a new particle set for time $t + 1$, given a particle set for time $t$.

In [5]:
def next_step(pset):
    """Applies a state transition to creates a new particle set for time t + 1.
    """
    num_particles = pset.num_particles
    pset_next = PSet(num_particles)
    acc = stats.norm.rvs(size=num_particles)
    pset_next["vel"] = pset["vel"] + acc
    pset_next["pos"] = pset["pos"] + pset["vel"] + acc / 2
    pset_next.belief = True
    return pset_next 

Time $t = 1$

In [6]:
pset1 = next_step(pset0)
In [7]:
pset1.mean()
Out[7]:
(0.0102, 0.0204)
In [8]:
pset1.covariance()
Out[8]:
array([[0.24525918, 0.49051835],
       [0.49051835, 0.98103671]])

How does the particle filter, which is basically a numerical simulation, compare with the closed form solution that was generated by the Kalman filter (KF)? Not bad.

KF results at time $t = 1$:

$$ \tag{4} \vec \mu_0 = \left\lgroup \begin{matrix}0 \cr 0 \end{matrix} \right\rgroup \\ \bar{\mathbf{\Sigma}_1} = \left\lgroup \matrix{\frac{1}{4} & \frac{1}{2} \cr \frac{1}{2} & 1} \right\rgroup $$

Time $t = 2$

PF Results

In [9]:
pset2 = next_step(pset1)
In [10]:
pset2.mean()
Out[10]:
(0.0524, 0.064)
In [11]:
pset2.covariance()
Out[11]:
array([[2.42258255, 1.9194373 ],
       [1.9194373 , 1.91131863]])

KF Results

$$ \tag{5} \vec \mu_0 = \left\lgroup \begin{matrix}0 \cr 0 \end{matrix} \right\rgroup \\ \bar{\mathbf{\Sigma}_1} = \left\lgroup \matrix{2.5 & 2 \cr 2 & 2} \right\rgroup $$

Time $t = 3$

PF Results

In [12]:
pset3 = next_step(pset2)
In [13]:
pset3.mean()
Out[13]:
(0.1306, 0.0923)
In [14]:
pset3.covariance()
Out[14]:
array([[8.43821131, 4.34990106],
       [4.34990106, 2.92600436]])

KF Results

$$ \tag{6} \vec \mu_0 = \left\lgroup \begin{matrix}0 \cr 0 \end{matrix} \right\rgroup \\ \bar{\mathbf{\Sigma}_1} = \left\lgroup \matrix{8.75 & 4.5 \cr 4.5 & 3} \right\rgroup $$

Time $t = 4$

PF Results

In [15]:
pset4 = next_step(pset3)
In [16]:
pset4.mean()
Out[16]:
(0.2238, 0.0941)
In [17]:
pset4.covariance()
Out[17]:
array([[20.36865344,  7.83448932],
       [ 7.83448932,  3.94561102]])

KF Results

$$ \tag{7} \vec \mu_0 = \left\lgroup \begin{matrix}0 \cr 0 \end{matrix} \right\rgroup \\ \bar{\mathbf{\Sigma}_1} = \left\lgroup \matrix{21 & 8 \cr 8 & 4} \right\rgroup $$

Time $t = 5$

PF Results

In [18]:
pset5 = next_step(pset4)
In [19]:
pset5.mean()
Out[19]:
(0.32, 0.0986)
In [20]:
pset5.covariance()
Out[20]:
array([[40.00402708, 12.04968096],
       [12.04968096,  4.94194431]])

KF Results

$$ \tag{8} \vec \mu_0 = \left\lgroup \begin{matrix}0 \cr 0 \end{matrix} \right\rgroup \\ \bar{\mathbf{\Sigma}_1} = \left\lgroup \matrix{41.25 & 12.5 \cr 12.58 & 5} \right\rgroup $$

Measurement Update

The remainder of the particle filter, lines 5 through 12, all address the measurement update. For this exercise, we will assume that we receive a measurement of position at time $t = 5$. The measurement is centered at the true position with a covariance of 10. The function weights implements lines 5 and 6 of the PF algorithm by calculating the probability of receiving a measurement of 5 for every particle in the set. Specifically:

$$\tag{9} w_t^{[m]} = p(z_t \mid x_t^{[m]})$$

In [21]:
MEASUREMENT_VARIANCE = 10

def weight(pos, meas, var):
    return stats.norm.pdf(meas, loc=pos, scale=math.sqrt(var))

weights = np.vectorize(weight, excluded=["pos", "var"], otypes=[np.float16])

Now we have to:

  1. Assign the weights to the weights column of the structured array contained within our PSet object.
  2. Create an empirical cumulative distribution function (CDF) based on the particle weights.
In [22]:
pset5["weights"] = weights(pset5["pos"], 5,
                           MEASUREMENT_VARIANCE)  # Assign weights
pset5.set_cdf()  # Create empirical CDF from weights.
pset5.measurement = 5

Let's take a look at the first few lines of the particle set:

In [23]:
pset5[0:10]
Out[23]:
array([( 7.113 ,  2.207 , 0.10089111, 0.10089111),
       ( 0.3618, -1.731 , 0.04302979, 0.1439209 ),
       ( 1.432 ,  2.172 , 0.06677246, 0.21069336),
       ( 2.588 ,  1.172 , 0.09429932, 0.30499268),
       ( 0.5137,  0.0984, 0.04611206, 0.35110474),
       (-2.041 , -1.051 , 0.01057434, 0.36167908),
       ( 6.1   ,  3.045 , 0.11871338, 0.48039246),
       ( 7.312 ,  2.162 , 0.09655762, 0.5769501 ),
       (-3.84  , -1.173 , 0.00253487, 0.57948494),
       ( 3.549 ,  2.004 , 0.11352539, 0.69301033)],
      dtype=[('pos', '<f2'), ('vel', '<f2'), ('weights', '<f4'), ('cdf', '<f4')])

The closer the position (1st column) is to 5, the higher the weights (3rd column), as expected. The 4th column contains the empirical probability distribution function that will be used for resampling.

Finally, we'll complete lines 8 through 11 of the PF algorithm via the PSet.resample() method.

In [24]:
post5 = pset5.resample()

Here are the results:

In [25]:
post5.mean()
Out[25]:
(4.023, 1.174)
In [26]:
post5.covariance()
Out[26]:
array([[7.65575305, 2.37378284],
       [2.37378284, 1.97499238]])

Note that the mean position is skewed towards the pre-measurement belief, even though our algorithm assigns the highest probability for particles with positions closest to 5, our measurement. This happens because there are just a lot more particles close to the pre-measurement mean than there are close to the measurement. So our resulting mean is affected by both the particle density before the measurement and the measurement weights. Just like the Kalman filter, the particle filter will discount the measurement if it's unlikely that the robot was in a state that was likely to result in that measurement.

KF Results

$$ \tag{10} \vec \mu_0 = \left\lgroup \begin{matrix}4.2 \cr 1.22 \end{matrix} \right\rgroup \\ \bar{\mathbf{\Sigma}_1} = \left\lgroup \matrix{8.05 & 2.44 \cr 2.44 & 1.95} \right\rgroup $$

The PF results compare favorably with the results from the Kalman filter. We could improve the accuracy by adding more particles, at the cost of requiring more computation.

Visualization

Comparison of belief and posterior at time $t = 5$:

In [27]:
pset5.scatter_plot("Robot State at Time 5")
In [28]:
post5.scatter_plot("Posterior at Time 5, z = 5", lims=[(-15, 15), (-5, 5)])

The post-measurement plot suggests the position is somewhere between 0 and 10. But keep in mind that the posterior particle set has many duplicate particles, which are plotting on top of each other in the scatter plot. Consequently the scatter plot gives the impression that there is more uncertainty than what is indicated by the covariance matrix. We'll have to use a histogram or other sort of plot that accounts for duplicate particles to improve the visualization.

Let's take a loot at the posterior to see the duplicate particles. There should be more duplicate particles for particles with positions closer to our measurement or the mean of the pre-measurement belief.

In [29]:
post5.contour_plot("Posterior at Time 5, z = 5")
C:\Users\stacy\Anaconda3\lib\site-packages\numpy\core\fromnumeric.py:52: RuntimeWarning: overflow encountered in multiply
  return getattr(obj, method)(*args, **kwds)