Fourier Series Decomposition and Fourier Coefficient Affine Superimposition of Kinematic Data

Supplementary tutorial for the article

Mielke, F., Van Ginneken, C., and Aerts, P. (2019). Quantifying intralimb coordination of terrestrial ungulates with Fourier Coefficient Affine Superimposition. Zoological Journal of the Linnean Society. https://doi.org/10.1093/zoolinnean/zlz135

See also http://mielke-bio.info/falk/dzg for a presentation of the method.

June 2019; corresponding author: Falk Mielke (falkmielke.biology@mailbox.org)


Content:

Fourier Series Deconstruction (FSD)

Superimposition Operations

Fourier Coefficient Affine Superimposition

Signal Averaging


For easier import to other projects, most code provided herein is also contained in the python toolbox file that we provide alongside this tutorial.

Although Python is our programming language of choice, the examples below are of general interest and can be ported to other environments. We hope that readers, independent of their programming background, will benefit from the clarity of Python and the extensive comments on the code below.


Libraries

This script will use several standard Python libraries.

In [1]:
import numpy as NP # numerical operations and array handling
import numpy.linalg as LA # linear algebra, for example vector norm calculation
import pandas as PD # data table management

# plotting
import matplotlib as MP
import matplotlib.pyplot as MPP
import matplotlib.colors as MPC
import matplotlib.cm as CM

%matplotlib inline


# display precision
NP.set_printoptions(precision = 2)
PD.options.display.precision = 2
PD.options.display.float_format = lambda flt: "%.2f" % flt

Also, some plots occur repeatedly, so here are helper functions for creation.

In [2]:
# plotting convenience
def EqualLimits(ax):
    # provide a plot with equal units on both axes
    limits = NP.concatenate([ax.get_xlim(), ax.get_ylim()])
    max_lim = NP.max(NP.abs(limits))
    
    ax.set_xlim([-max_lim, max_lim])
    ax.set_ylim([-max_lim, max_lim])
    
    
def MakeSignalFigure():
    # a standard FSD figure that will be repeatedly used below
    fig = MPP.figure(figsize = (24/2.54, 8/2.54), dpi=150)
    fig.subplots_adjust( \
                              top    = 0.82 \
                            , right  = 0.98 \
                            , bottom = 0.09 \
                            , left   = 0.10 \
                            , wspace = 0.08 # column spacing \
                            , hspace = 0.20 # row spacing \
                            )
    rows = [12]
    cols = [5,2]
    gs = MP.gridspec.GridSpec( \
                              len(rows) \
                            , len(cols) \
                            , height_ratios = rows \
                            , width_ratios = cols \
                            )
    time_domain = fig.add_subplot(gs[0]) # , aspect = 1/4
    time_domain.axhline(0, ls = '-', color = '0.5', lw = 1, zorder = 0)
    time_domain.set_xlabel(r'stride cycle')
    time_domain.set_ylabel(r'angle')
    time_domain.set_title('time domain')
    time_domain.set_xlim([0.,1.])


    frequency_domain = fig.add_subplot(gs[1], aspect = 'equal')
    frequency_domain.axhline(0, ls = '-', color = '0.5', lw = 1, zorder = 0)
    frequency_domain.axvline(0, ls = '-', color = '0.5', lw = 1, zorder = 0)

    frequency_domain.set_xlabel(r'$\Re(c_n)$')
    frequency_domain.set_ylabel(r'$\Im(c_n)$')
    frequency_domain.set_title('frequency domain')

    frequency_domain.yaxis.tick_right()
    frequency_domain.yaxis.set_label_position("right")

    return fig, time_domain, frequency_domain

Fourier Series Decomposition (FSD) $\uparrow$


In addition to the references from the main text, we recommend the online lecture by Brad Osgood, provided by the Stanford University for readers not entirely familiar with Fourier methods:

https://see.stanford.edu/Course/EE261

https://www.youtube.com/watch?v=gZNm7L96pfY&list=PLB24BC7956EE040CD

Python Function Helpers

A lot of functions are required to facilitate the FSD process. All of them are standard maths.

Complex Numbers

We will need some functions to conveniently handle complex numbers. Note that 1j in numpy is the imaginary number $i$.

In [3]:
# a common factor in Fourier complex exponentials
two_pi_j = 2*NP.pi*1j 

# convert a vector of two columns (where one is the real and the other the imaginary part) to a complex 1D vector.
MakeComplex = lambda twocolumnvec: NP.add(twocolumnvec[:,0], 1j*twocolumnvec[:,1])

# and the inverse: a complex vector to two columns.
ComplexToMatrix = lambda complex_array: NP.stack([NP.real(complex_array), NP.imag(complex_array)], axis = 1)

Fourier Series

If signals $f(t)$ are real, and in case of finite sampling, we can simplify the Fourier Series of order $N$ to positive coefficients $c_{n}$ by combining complex conjugate coefficients $\Re(c_n + c_{-n}) = \Re(c_n + \overline{c_n}) = 2\Re(c_{n})$: \begin{equation}\label{fourier_series} f(t) = \sum\limits_{n=-N}^{N} \cdot c^{*}_{n}\cdot e^{2\pi i n \frac{t}{T}} = \sum\limits_{n=0}^{N} (2\cdot c_{n})\cdot e^{2\pi i n \frac{t}{T}} \end{equation}

With the coefficients defined as follows: \begin{equation}\label{fourier_coefficients} c_{n} = \frac{1}{T}\sum\limits_{t=0}^{T} e^{-2\pi i n \frac{t}{T}} \cdot f(t) \quad\quad \forall n> 0 \end{equation}

The zero'th coefficient is the temporal average of the angle: \begin{equation} c_{0} = \cdot \frac{1}{T}\sum\limits_{t=0}^{T} e^{0} f(t) = \left\langle f(t) \right\rangle \end{equation}

The following functions are used to perform Fourier Series Decomposition. There is no regression or fitting procedure involved, because the transformation procedures are deterministic.

In [4]:
### Fourier Series Decomposition
# take a signal from time domain to frequency domain
# i.e. give Fourier coefficients c_n for a signal
SingleCoefficient = lambda t, T, n: NP.exp(-two_pi_j*n*t/T)
FourierSeriesDecomposition = lambda time, period, signal, order: \
                                        (1/( period*( NP.max(time)-NP.min(time) ) )) \
                                        * NP.array([ NP.sum(signal*SingleCoefficient(time, period, n))/len(time)  \
                                               for n in range(order+1) \
                                             ])

### Fourier Series Synthesis (Reconstruction)
# reconstruct a signal from its frequency domain representation
# i.e. take coefficients and return the signal
FourierSummand = lambda t, T, cn, n: (1 if n == 0 else 2)*cn*NP.exp(two_pi_j*n*t/T) 
FourierFunctionT = lambda t, T, coeffs: NP.sum(NP.array([ FourierSummand(t, T, cn, n) \
                                                           for n,cn in enumerate(coeffs) \
                                                          ]))

FourierReconstruction = lambda time, period, coeffs: NP.real(period * (NP.max(time)-NP.min(time)) \
                                                    * NP.array([FourierFunctionT(t, period, coeffs) for t in time]) \
                                                            ) 

### Delay Theorem
# Shifting a periodic signal in time shifts its phase, hence the fourier coefficients are rotated in the complex plane.
# Rotation corresponds to multiplication with a complex exponential!
# Further reading:
#    https://ece.umd.edu/~tretter/enee322/FourierSeries.pdf p15 Delay theorem
#    https://dsp.stackexchange.com/questions/42341/fourier-series-time-shift-and-scaling
#    http://www.thefouriertransform.com/transform/properties.php
#    https://www.dsprelated.com/freebooks/mdft/Shift_Theorem.html

DelayFactor = lambda delay, n: NP.exp(two_pi_j*n*delay)
# the delay factor will be multiplied to complex coefficients to produce a phase shift.

The delay theorem is particularly interesting: $$ c_n(t+dt) = c_n (t) \cdot e^{2\pi i\ n\ dt}$$

Due to the $n$ in the exponential, the phases of a signal's coefficients will be at a slope that corresponds to the phase itself. This property is worth a little more illustration.

Take a signal that has its phase around zero. Then, rotate it stepwise.

alt text

Note how the $n$'th coefficient rotates $n$ times as fast as the first one. The zero'th coefficient, which is always on the real axis, does not change.

Note that this also introduces our general plotting scheme:

  • On the left, there is the "time domain", i.e. the observed real signal. In this case this is the change of an angle over time.
  • The panel on the right shows the "frequency domain", spanned by the real and imaginary axis, where the Fourier coefficients of the signal are plotted.
  • Transformation between those domains happens via Fourier Series decomposition and synthesis.

Amplitude and Phase

Complex Fourier coefficients can be transformed to values that is informative in a more intuitive way, namely amplitude $A_{n}$ and phase $\phi_{n}$.

$$A_{n} = \sqrt{\Re(c_{n})^{2}+\Im(c_{n})^{2}} \ge 0$$$$\phi_{n} = \frac{1}{2\pi}\cdot arctan2\left( -\Im(c_{n}),\Re(c_{n})\right) \quad\quad \left( 0 \le \phi_{n} < 1 \quad \forall n\right)$$
In [5]:
### amplitude and phase transformations
# working on two column (Re, Im) vectors
Amplitude = lambda values: NP.sqrt(NP.sum(NP.power(values, 2), axis = 1))
Phase = lambda values: NP.arctan2(-values[:,1],values[:,0])

# get complex coefficients from amplitude and phase
ReConvertAmpPhi = lambda amp, phi: (  amp/(NP.sqrt(1+(NP.tan(phi))**2)) \
                                    , amp*NP.tan(phi)/(NP.sqrt(1+(NP.tan(phi))**2))  \
                                    )

There are also formulas to extract the compound amplitude $A$ and phase $\Phi$ of a set of complex numbers. They are implemented below, and the formulas are:

$$ A = \sum\limits_{n > 0} A_{n} $$$$ \Phi = \frac{\sum_{n>0}^{N} (\phi_{n}-\phi_{n-1})\cdot \frac{A_{n}}{n}}{\sum_{n>0} \frac{A_{n}}{n}} $$

This last formula has, to our knowledge, not been used before. It exploits properties of the delay theorem which were illustrated above.

Angular Transformations

Some standard operations in polar coordinates.

In [6]:
# coordinate transformations
def Cart2Polar(x, y):
    r = NP.sqrt(x**2 + y**2)
    theta = NP.arctan2(y, x)
    return r, theta

def Polar2Cart(r, phi):
    x = r * NP.cos(phi)
    y = r * NP.sin(phi)
    return x, y


def WrapToInterval(vector, lower = 0, upper = 2*NP.pi):
    if not (type(vector) is int):
        vector = vector.copy()
    vector -= lower
    vector = NP.mod(vector, upper - lower)
    vector += lower

    return vector



# the opposite of NP.unwrap()
WrapAngle = lambda angle: NP.arctan2(NP.sin(angle), NP.cos(angle))

# smallest angle between two vectors
AngleBetween = lambda vec1, vec2: NP.arccos(NP.dot( vec1/LA.norm(vec1), vec2/LA.norm(vec2) )) 

# angle between two vectors, counter-clockwise from second to first point
AngleBetweenCCW = lambda p1, p2: (NP.arctan2(*p1[::-1]) - NP.arctan2(*p2[::-1])) % (2 * NP.pi)

# angle between two vectors, counter-clockwise, but wrapped to [-pi, pi]
DirectionalAngleBetween = lambda vector, reference_vector: \
        WrapToInterval( (NP.arctan2(vector[1], vector[0]) - NP.arctan2(reference_vector[1], reference_vector[0]) ) \
                    , lower = -NP.pi, upper = NP.pi \
                    )

# rotation matrix in 2D, rotation counter-clockwise
RotationMatrixCCW = lambda ang: NP.array([ \
                                  [NP.cos(ang), -1*NP.sin(ang)] \
                                , [NP.sin(ang),    NP.cos(ang)] \
                                ])

Testing Fourier Series Decomposition

To check whether the transformation was correct, arbitrary coefficients are taken, synthethized to a signal, decomposed again, and so forth.

In [7]:
### testing Fourier formulas
# example data (taken from a giraffe carpus)
coefficients = PD.DataFrame.from_dict({'re': [0.06,  0.05, -0.12, -0.03,  0.01, -0.00]
        , 'im': [0.00,  0.21,  0.06, -0.04, -0.01, -0.01]})
coefficients_C = MakeComplex(coefficients.values)

# FSD parameters
period = 0.5 # default would be one, but other periods are possible
order = coefficients.shape[0]-1
x_reco = NP.linspace(0.,2.,200, endpoint = False)


# reconstruct signal
signal = FourierReconstruction(x_reco, period, coefficients_C)


# decompose it again
recovered_coefficients_C = FourierSeriesDecomposition(x_reco, period, signal, order)
recovered_coefficients = ComplexToMatrix(recovered_coefficients_C)
recovered_coefficients = PD.DataFrame(recovered_coefficients, columns = ['re', 'im'])
print (recovered_coefficients)

# and reconstruct once more
recovered_signal = FourierReconstruction( x_reco, period, recovered_coefficients_C )


### plot for comparison
fig, signal_domain, frequency_domain = MakeSignalFigure()
signal_domain.plot(x_reco, signal, 'k-', lw = 2)
signal_domain.plot(x_reco, recovered_signal, 'r--', lw = 1)

print ('mean', NP.mean(signal))
signal_domain.axhline(NP.mean(signal), ls = ':', color = '0.5', lw = 1)

frequency_domain.scatter(coefficients.loc[:, 're'], coefficients.loc[:, 'im'], s = 50, marker = 'o', color = 'k')
frequency_domain.scatter(recovered_coefficients.loc[:, 're'], recovered_coefficients.loc[:, 'im'], s = 50, marker = '+', color = 'r')
  
EqualLimits(frequency_domain)
signal_domain.set_xlim([0, 2.])
MPP.show();
     re    im
0  0.06  0.00
1  0.05  0.21
2 -0.12  0.06
3 -0.03 -0.04
4  0.01 -0.01
5  0.00 -0.01
mean 0.05970000000000012

The singnal and reconstruction match in time- and frequency domain! This actually proves that the transformations are preserving all the information (theoretically, i.e. if the number of coefficients is infinite).

This might not be the case when coefficients are estimated by regression. Hence, just like it is demonstrated here, a reconstruction of Fourier coefficients should always be performed to validate the decomposition procedure. Validation is essential if a fitting procedure was part of the algorithm (which is generally not a good strategy for Fourier procedures).

Wrapping this into an Object

Now take this test case to an object for easier handling.

IMPORTANT: Readers not interested in the actual implementation can skip this section and continue with the application of FSD.

In [8]:
### Fourier Series Decomposition of a Signal
class FourierSignal(object):
    """
    Wrapper object for a Fourier coefficient data frame of the structure:
         n    re    im
         0  0.06  0.00
         1  0.05  0.21
         2 -0.12  0.06
         ...

    Facilitates parsing of the coefficients to different formats.
    Multiple constructors: FromDataFrame (default behavior), FromComplexVector, FromSignal
    
    Can also be used to reconstruct arbitrary coefficients to a signal.
        
    """
### constructors
    def __init__(self, coeffs_C, label = None, raw_signal = None):
        # coeffs_C: a complex vector of coefficients
        self.label = label # for bookkeeping
        self._c = PD.DataFrame.from_dict({ \
                      'n': range(coeffs_C.shape[0]) \
                    , 're': NP.real(coeffs_C) \
                    , 'im': NP.imag(coeffs_C) \
                    }).set_index(['n'], inplace = False).loc[:,['re','im']]
        
        self.raw_signal = raw_signal # optional storage; not used
        
        
    @classmethod
    def FromDataFrame(cls, coeffs_df, label = None, raw_signal = None):
        # creates a Fourier Coefficients instance from data frame storage
        coefficients = cls(MakeComplex(coeffs_df.copy().values), label, raw_signal)
        return coefficients
        
    @classmethod
    def FromComplexVector(cls, coeffs_C, label = None, raw_signal = None):
        # creates a Fourier Coefficients instance from a complex vector of coefficients
        coefficients = cls(coeffs_C, label, raw_signal)
        return coefficients
        
    @classmethod
    def FromSignal(cls, time, signal, order = 5, period = 1., label = None):

        if len(signal) < (order * 2):
            WRN.warn("\nrecording %s (%i samples): Fourier Series order (%i) beyond Nyquist frequency." % (str(label), len(signal), order))

        # creates a Fourier Coefficients instance from a signal
        coeffs_C = FourierSeriesDecomposition(time, period, signal, order)
        
        signal_df = PD.DataFrame.from_dict({'time': time, 'signal': signal}).set_index('time', inplace = False)
        coefficients = cls(coeffs_C, label, raw_signal = signal_df)
        return coefficients
        
### frequency space to signal space
    def Reconstruct(self, x_reco = None, new_order = None, period = None):
        # reconstruct a signal 
        #   based on the given coefficients
        #   and a sampling vector

        if (new_order is None) or (new_order > (len(self))):
            new_order = len(self)
        if x_reco is None:
            x_reco = NP.linspace(0.,1.,100, endpoint = False)
        if period is None:
            period = x_reco[-1] + (x_reco[-1] - x_reco[0]) / len(x_reco)

        return FourierReconstruction( x_reco, period, self.GetVector() )

    
    def Plot(self, ax = None, x_reco = None, centered = True, y_offset = 0., plot_kwargs = {}, peak_kwargs = None):

        # replace missing arguments
        if x_reco is None:
            x_reco = NP.linspace(0.,1.,101, endpoint = True)
        if ax is None:
            ax = MPP.gca()

        # reconstruct signal
        y_reco = self.Reconstruct(x_reco, period = 1.)
        if centered:
            y_reco -= self.GetCentroid()

        # plot signal
        ax.plot(x_reco, y_offset+y_reco, **plot_kwargs)

        # scatter peak = phase as well
        if peak_kwargs is not None:
            # get curve peak
            peak = self.GetMainPhase()
            
            # invert to get trough
            inverse = self.Copy()
            Scale(inverse, -1.)
            trough =  WrapToInterval( inverse.GetMainPhase(), lower = 0., upper = 1.)

            # scatter
            x_minmax = NP.array([x_reco[0], trough, peak, x_reco[-1]])
            y_minmax = self.Reconstruct(x_minmax, period = 1.)
            if centered:
                y_minmax -= self.GetCentroid()

            ax.scatter(x_minmax[1:3], y_offset+y_minmax[1:3], **peak_kwargs)




    
### get attributes of the signal   
    def GetVector(self):
        # returns the coefficients as a complex vector
        return MakeComplex(self[:,:].values)

    def GetCoeffDataFrame(self):
        # returns the coefficients as a data frame
        return self[:,:]
    
    
    def GetCentroid(self):
        # returns the curve mean, which is the 0th coefficient
        return self[0, 're']

    def GetAmplitudes(self):
        # returns the amplitudes of coefficients
        # which is the Euclidean distance of all n>0 coefficients from center in complex plane
        return Amplitude(self._c.iloc[1:, :].values)

    def GetPhases(self):
        # returns the phases of coefficients
        # which is the complex angle of all n>0 coefficients in complex plane
        return Phase(self._c.iloc[1:, :].values)

    def GetMainPhase(self):
        # returns the default phase of the signal
        # which can actually be determined by the slope of the phases
        # http://www.dspguide.com/ch10/2.htm

        # use a ghost copy of the signal
        ghost = self.Copy()
        Center(ghost) # ... needs to be centered

        # first rotate first coefficients phase to approx zero to avoid +-pi discontinuity
        initial_phase = Phase(self[1:, :].values)[0] 
        initial_phase /= 2. * NP.pi

        # rotate the signal (explained below)
        ghost[1:, :] = ComplexToMatrix( NP.array( [coeff / DelayFactor(-initial_phase, n)  \
                                      for n, coeff in enumerate(ghost.GetVector()) if n > 0] ))
        

        # get phases and amplitudes
        phases = NP.unwrap( Phase(ghost[:, :].values) )

        # get phase differential
        # phases -= phases[0]
        d_phi = NP.diff(phases)
        
        # use amplitude weighted mean
        #phaseshift = NP.average(d_phi, weights = amplitudes)
        amplitudes = ghost.GetAmplitudes()
        if NP.all(amplitudes == 0):
            amplitudes = NP.ones(amplitudes.shape)

        # amplitudes /= amplitudes[0]
        phaseshift = NP.average(d_phi, weights = NP.divide(amplitudes, 1+NP.arange(len(amplitudes))))
        
        # bring to unit interval
        phaseshift /= 2. * NP.pi

        return WrapToInterval( initial_phase + phaseshift, lower = 0., upper = 1.)
    
    
    

### helper functions: Python magic (https://www.python-course.eu/python3_magic_methods.php)
    def DistanceFrom(self, reference):
        # Euclid distance in frequency space from another Fourier Series Decomposition
        # not used; can be adjusted to a convenient metric.
        return NP.sum(NP.abs(self[:,:].values - reference), axis = (0,1))

    def Copy(self):
        return FourierSignal.FromDataFrame(self._c, self.label, self.raw_signal)

    def __len__(self):
        # returns the order
        return self._c.shape[0]-1

    def __getitem__(self, selection):
        return self._c.loc[selection]

    def __setitem__(self, selection, values):
        self._c.loc[selection] = values

    def __add__(self, value):
        coeffs = self[:].copy()
        coeffs[0, 're'] += value
        return coeffs

    def __iadd__(self, value):
        self[0, 're'] += value
        return self

    def __sub__(self, value):
        coeffs = self[:].copy()
        coeffs[0, 're'] -= value
        return coeffs

    def __isub__(self, value):
        self[0, 're'] -= value
        return self


    def __mul__(self, value):
        coeffs = self[:].copy()
        coeffs[1:, :] *= value
        return coeffs

    def __imul__(self, value):
        self[1:, :] *= value
        return self

    def __div__(self, value):
        coeffs = self[:].copy()
        coeffs[1:, :] /= value
        return coeffs

    def __idiv__(self, value):
        self[1:, :] /= value
        return self
    
    
    def __str__(self):
        return """%s%s%s""" % ("_"*24, '\n' if self.label is None else "\n%s\n"%(self.label), str(self._c))
In [9]:
### test this as before
period = 0.5
order = coefficients.shape[0]-1
x_reco = NP.linspace(0.,2.,201, endpoint = True)
time = NP.linspace(0.,1.,100, endpoint = False)

fsd = FourierSignal.FromComplexVector(coefficients_C)

signal = fsd.Reconstruct(x_reco = x_reco, period = period)

restore_coeffs = FourierSignal.FromSignal(  time \
                                                , fsd.Reconstruct(x_reco = time, period = 1.) \
                                                , period = 1., order = order \
                                               )
print (restore_coeffs)

MPP.plot(x_reco, signal, 'k-');
________________________
     re    im
n            
0  0.06  0.00
1  0.05  0.21
2 -0.12  0.06
3 -0.03 -0.04
4  0.01 -0.01
5 -0.00 -0.01

This syntax is much more convenient, although the result is similar to what was obtained above.


Superimposition Operations $\uparrow$


As was already indicated in the code above, a signal in frequency domain has three main transformation operations:

  • translation (shifting in y-direction): adjust the temporal mean of the signal
  • scaling: increase/decrease the amplitude around the mean
  • rotation (phase shifting): shift periodic signal in the time direction (= delay)

Those three are the affine transformations. Affine means that they are modified by a linear, constant factor. Signals that differ in affine components have the same shape (as per definition of shape).

Below, functions are prepared to apply each of these transformations to the signal. Also, reference points can be associated with each of these operations:

  • translation $\leftrightarrow$ center: mean of the signal is zero
  • scaling $\leftrightarrow$ normalize: amplitudes sum to one
  • rotation $\leftrightarrow$ dephase: combined phase of the signal is zero, thus phase slope is also zero

Standardization functions transform a signal to these reference points.

Shifting

This operation refers to changing the mean of the signal. In frequency space, the mean is the zero'th coefficient $c_0$.

Here are the functions that achieve it:

In [10]:
def Shift(fsd, shift):
    # shift coeffitients to change mean of the original signal
    fsd += shift # behavior defined by the "__iadd__" function above

def Center(fsd):
    # shift the signal to its mean, i.e. set first component to zero
    mean = fsd[0, 're']
    fsd[0, 're'] = 0
    return mean

And here is a test to see what it looks like on a signal. This code

  • takes a signal
  • plots it for reference (grey)
  • rotates, i.e. phase shifts it
  • plots the shifted signal (black)
  • centers to $c_0 = 0$
  • plots again (green)
In [11]:
### testing
test_fsd = fsd.Copy()
Shift(test_fsd, 0.2)
time = NP.linspace(0.,1.,101, endpoint = True)

### plot for comparison
fig, signal_domain, frequency_domain = MakeSignalFigure()

# reference signal
signal_domain.plot(time, fsd.Reconstruct(time), ls = '--', lw = 1, color = '0.5', label = 'original')
signal_domain.axhline(fsd.GetCentroid(), ls = ':', color = '0.5', lw = 1)
frequency_domain.scatter(fsd[:, 're'], fsd[:, 'im'], s = 50, marker = '+', color = '0.5')

# modified signal
signal_domain.plot(time, test_fsd.Reconstruct(time), 'k-', lw = 2, label = 'shifted')
signal_domain.axhline(test_fsd.GetCentroid(), ls = ':', color = 'k', lw = 1)
frequency_domain.scatter(test_fsd[:, 're'], test_fsd[:, 'im'], s = 50, marker = 'o', color = 'k')

# highlight the change
frequency_domain.scatter(fsd[0, 're'], fsd[0, 'im'], s = 120, marker = 'o', edgecolor = 'r', facecolor = 'none')
frequency_domain.scatter(test_fsd[0, 're'], test_fsd[0, 'im'], s = 150, marker = 'o', edgecolor = 'r', facecolor = 'none')

# standard signal
Center(test_fsd)
signal_domain.plot(time, test_fsd.Reconstruct(time), ls = '-', lw = 1, color = (0.2,0.4,0.2), label = 'centered')
frequency_domain.scatter(test_fsd[:, 're'], test_fsd[:, 'im'], s = 50, marker = 'x', color = (0.2,0.4,0.2))


EqualLimits(frequency_domain)
signal_domain.legend()
MPP.show();

Red circles show the only frequency domain change due to shift: the zero'th coefficient.

Scaling

Another affine transformation is scaling. In the frequency domain, this operator should not affect the signal mean ($c_0$), hence the signal is scaled around its mean, thereby changing in amplitude.

In [12]:
def Scale(fsd, scaling):
    # scale the coeffitients by a factor
    fsd *= scaling

def Normalize(fsd):
    # scale the coefficients to unit amplitude
    amplitude = NP.sum(fsd.GetAmplitudes())
    Scale(fsd, 1./amplitude)
    return amplitude

This can be illustrated:

In [13]:
### testing
test_fsd = fsd.Copy()
Scale(test_fsd, 1.5)
time = NP.linspace(0.,1.,101, endpoint = True)

### plot for comparison
fig, signal_domain, frequency_domain = MakeSignalFigure()

# reference signal
signal_domain.plot(time, fsd.Reconstruct(time), ls = '--', lw = 1, color = '0.5', label = 'original')
signal_domain.axhline(fsd.GetCentroid(), ls = ':', color = '0.5', lw = 1)
frequency_domain.scatter(fsd[:, 're'], fsd[:, 'im'], s = 50, marker = '+', color = '0.5')

# modified signal
signal_domain.plot(time, test_fsd.Reconstruct(time), 'k-', lw = 2, label = 'scaled')
signal_domain.axhline(test_fsd.GetCentroid(), ls = ':', color = 'k', lw = 1)
frequency_domain.scatter(test_fsd[:, 're'], test_fsd[:, 'im'], s = 50, marker = 'o', color = 'k')

# standard signal
Normalize(test_fsd)
signal_domain.plot(time, test_fsd.Reconstruct(time), ls = '-', lw = 1, color = (0.2,0.4,0.2), label = 'normalized')
frequency_domain.scatter(test_fsd[:, 're'], test_fsd[:, 'im'], s = 50, marker = 'x', color = (0.2,0.4,0.2))


EqualLimits(frequency_domain)
signal_domain.legend()
MPP.show();

The dotted horizontal line is the mean, which is unaffected by scaling.

This illustrates that, in frequency domain, the amplitude is related to the distance of coefficient points (excluding $c_0$) from the origin.

Rotation

Finally, a phase shift is possible, which translates to a non-uniform rotation in frequency space (as shown above).

This can be implemented as follows for the FSD object above. There's a bit of arbitrary convention choice involved (rotation direction, interval in units of signal period).

In [14]:
def Rotate(fsd, delay):
    # uses the delay factor to rotate the coefficients in complex space
    # actually rotates this coefficient instance
    # ! delay should be a number within the unit interval [0; 1]; sign indicates direction
    # positive delay ==> clockwise rotation, shift to right
    fsd[1:, :] = ComplexToMatrix( NP.array( [coeff / DelayFactor(delay, n) for n, coeff in enumerate(fsd.GetVector()) if n > 0] ))


def DePhase(fsd):
    # rotates the coefficients to the best phase alignment
    phase = fsd.GetMainPhase()
    Rotate(fsd, -phase)
    return phase

And it can be put to another test.

In [15]:
### testing
test_fsd = fsd.Copy()
Rotate(test_fsd, -0.25)
time = NP.linspace(0.,1.,101, endpoint = True)

### plot for comparison
fig, signal_domain, frequency_domain = MakeSignalFigure()

# reference signal
signal_domain.plot(time, fsd.Reconstruct(time), ls = '--', lw = 1, color = '0.5', label = 'original')
signal_domain.axhline(fsd.GetCentroid(), ls = ':', color = '0.5', lw = 1)
frequency_domain.scatter(fsd[:, 're'], fsd[:, 'im'], s = 50, marker = '+', color = '0.5')

# modified signal
signal_domain.plot(time, test_fsd.Reconstruct(time), 'k-', lw = 2, label = 'rotated')
signal_domain.axhline(test_fsd.GetCentroid(), ls = ':', color = 'k', lw = 1)
frequency_domain.scatter(test_fsd[:, 're'], test_fsd[:, 'im'], s = 50, marker = 'o', color = 'k')

# standard signal
DePhase(test_fsd)
signal_domain.plot(time, test_fsd.Reconstruct(time), ls = '-', lw = 1, color = (0.2,0.4,0.2), label = 'aligned')
frequency_domain.scatter(test_fsd[:, 're'], test_fsd[:, 'im'], s = 50, marker = 'x', color = (0.2,0.4,0.2))


EqualLimits(frequency_domain)
signal_domain.legend()
MPP.show();

Phase shift works as expected.

Note that the standardized signal, i.e. the one with all coefficient phases as close as possible to zero, is the one that shows highest symmetry around time zero.

Standardization

Finally, all the presented operations allow for the computation of a standardized signal. This will have mean zero, amplitude of one, and phase zero.

In [16]:
### Standardization
def Standardize(fsd):
    # shift, scale and rotate to the default orientation

    ## center, i.e. shift the mean of the trace (Re(c0)) to zero
    shift_value = Center(fsd)

    ## normalize, i.e. scale so that FSD amplitudes sum to one
    scale_value = Normalize(fsd)

    ## dephase, i.e. rotate the cyclical curve to zero phase
    rot_value = DePhase(fsd)

    return shift_value, scale_value, rot_value
In [17]:
### testing
test_fsd = fsd.Copy()
time = NP.linspace(0.,1.,100, endpoint = False)

Standardize(test_fsd)

### plot for comparison
fig, signal_domain, frequency_domain = MakeSignalFigure()

# standard signal
signal_domain.plot(time, test_fsd.Reconstruct(time), ls = '-', lw = 1, color = (0.2,0.4,0.2), label = 'standardized')
frequency_domain.scatter(test_fsd[:, 're'], test_fsd[:, 'im'], s = 50, marker = 'x', color = (0.2,0.4,0.2))

# reference signal
signal_domain.plot(time, fsd.Reconstruct(time), ls = '--', lw = 1, color = '0.5', label = 'original')
signal_domain.axhline(fsd.GetCentroid(), ls = ':', color = '0.5', lw = 1)
frequency_domain.scatter(fsd[:, 're'], fsd[:, 'im'], s = 50, marker = '+', color = '0.5')



EqualLimits(frequency_domain)
signal_domain.legend()
MPP.show();

Fourier Coefficient Affine Superimposition $\uparrow$


The operations introduced above are defined in analogy to Procrustes Superimposition in Geometric Morphometrics.


When two signals are aligned (i.e. equal in mean, scale and phase), the difference that remains is a difference in shape.

Note that, because our example signal was constructed from Fourier coefficients, there will be no superimposition remainder (i.e. shape difference) in this example.

A "Procrustes" object is implemented below that conveniently aligns a FSD signal to a reference. The steps are:

(1) "Translation": shifts the signal by the difference in temporal mean.
(2) "Scaling": scales the signal to equalize amplitude.
(3) "Rotation": phase shifts by the difference in main signal phase.

The superimposition operaters are initially only calculated, and can be applied by a separate function ("ApplyTo").

In [18]:
class Procrustes(dict):
    """
    # scales, shifts and rotates a trace object to match the reference.
    """

    def __init__(self, fsd, reference, compute_distance = False, label = None):
        # calculates the alignment operators, but does not apply the transformations yet.

        if label is None:
            self.label = "PS %s onto %s" % (str(fsd.label), str(reference.label))
        else:
            self.label = label

        # use fourier decompositions
        reference_fsd = reference.Copy()
        signal_fsd = fsd.Copy()


    ## (1) Translation is first. It is independent of other transformations.
        # translation of the trace to their mean, i.e. set first component to zero
        translation = signal_fsd.GetCentroid() - reference_fsd.GetCentroid()

        # center both signals, prior to further calculation
        Center(reference_fsd)
        Center(signal_fsd)

    ## (2) scaling is independent of rotation, so it follows.
        # scaling = amplitude ratio
        scale = NP.sum(signal_fsd.GetAmplitudes()) / NP.sum(reference_fsd.GetAmplitudes()) 

        # scale both fsd coefficient objects
        Normalize(reference_fsd)
        Normalize(signal_fsd)

    ## (3) finally, find optimal rotation
        rotation = signal_fsd.GetMainPhase() - reference_fsd.GetMainPhase()

        # phase-align both fsd's
        DePhase(reference_fsd)
        DePhase(signal_fsd)


    ### store results
        self['translation'] = -translation
        self['scaling'] = 1/scale
        self['rotation'] = -rotation

        self.fsd = signal_fsd
        self.reference = reference_fsd        

        if compute_distance:
            self.ComputeDistances()


    def ComputeDistances(self):
        # compute residual Euclidean distances
        signal_fsd = self.fsd.Copy()
        reference_fsd = self.reference.Copy()
        
        ### Euclid distance in frequency domain
        euclid_distance = reference_fsd.DistanceFrom(self.fsd[:,:].values)

        self['residual_euclid'] = euclid_distance
        
    def HasDistances(self):
        return (self.get('residual_pd', None) is not None) or (self.get('residual_euclid', None) is not None)
        
    def GetDistances(self):
        if not self.HasDistances():
            self.ComputeDistances()
        return self['residual_euclid']


### Procrustes Superimposition
    def ApplyTo(self, fsd, skip_shift = False, skip_scale = False, skip_rotation = False):
        # shift, scale and rotate to a given reference

        ## shift
        if not skip_shift:
            Shift(fsd, self['translation'])
        
        ## scale
        if not skip_scale:
            Scale(fsd, self['scaling'])
        
        # rotate 
        if not skip_rotation:
            Rotate(fsd, self['rotation'])
        

### helpers
    def __str__(self):
        return "{label:s} \n\td_y = {translation:.2f}\n\td_A = {scaling:.2f}\n\td_phi = {rotation:.2f}".format( \
                                label = self.label \
                              , **self \
                             ) \
                + ("" if not self.HasDistances() \
                  else "\nresidual: {residual_euclid:.3f}".format(**self))
        

And here is how this works in practice:

In [19]:
### testing
# Standardize(fsd)
test_fsd = fsd.Copy()

# modify a signal
Shift(test_fsd, -0.1)
Scale(test_fsd, 1/1.2)
Rotate(test_fsd, 0.1)

# procrust the test signal onto the original one
superimposition = Procrustes(test_fsd, reference = fsd, compute_distance = False, label = 'test superimposition')
superimposition.GetDistances()
print (superimposition)

### plot for comparison
fig, signal_domain, frequency_domain = MakeSignalFigure()

# reference signal
signal_domain.plot(time, fsd.Reconstruct(time), ls = '--', lw = 3, color = '0.5', label = 'original')
signal_domain.axhline(fsd.GetCentroid(), ls = ':', color = '0.5', lw = 1)
frequency_domain.scatter(fsd[:, 're'], fsd[:, 'im'], s = 50, marker = '+', color = '0.5')

# modified signal
signal_domain.plot(time, test_fsd.Reconstruct(time), 'k-', lw = 2, label = 'modified')
signal_domain.axhline(test_fsd.GetCentroid(), ls = ':', color = 'k', lw = 1)
frequency_domain.scatter(test_fsd[:, 're'], test_fsd[:, 'im'], s = 50, marker = 'o', color = 'k')

# standard signal
superimposition.ApplyTo(test_fsd)
signal_domain.plot(time, test_fsd.Reconstruct(time), ls = '-', lw = 1, color = (0.2,0.4,0.2), label = 'aligned')
frequency_domain.scatter(test_fsd[:, 're'], test_fsd[:, 'im'], s = 50, marker = 'x', color = (0.2,0.4,0.2))

EqualLimits(frequency_domain)
signal_domain.legend()
MPP.show();
test superimposition 
	d_y = 0.10
	d_A = 1.20
	d_phi = -0.10
residual: 0.000

Signal Averaging $\uparrow$


The alignment can be used to produce a better average of the traces. This is analogous to Generalized Procrustes analysis (GPA).

Note that averaging can happen without modifying the raw data.

In [20]:
### Averaging of coefficients
def AverageCoefficients(coefficient_df_list):
    component_store = None
    n_observations = 0
    for coeff_df in coefficient_df_list:

        if component_store is None:
            component_store = coeff_df.copy()
        else:
            component_store.loc[:, :] += coeff_df.values

        n_observations += 1

    component_store.loc[:, :] /= n_observations

    return component_store, n_observations



def ProcrustesAverage(signals_raw, n_iterations = 1, skip_scale = True, post_align = False):
    # averages the signals in the input array in frequency domain
    # by aligning copies of them prior to averaging
    # this avoids problems of interference
    # parameters:
        # post_align (optional): align the original signals to the average
        # skip_scale (optional): do not scale the average signal, therefore retaining amplitude
    
    signals = [sig.Copy() for sig in signals_raw]
    
    ### coarse alignment
    # based on first coefficient phase
    #for sig in signals:
    #    Rotate(sig, -sig.GetPhases()[0] / (2*NP.pi))
        
        
    ### initial averaging
    PreliminaryAverage = lambda signal_array: FourierSignal.FromDataFrame( \
                                                          AverageCoefficients([sig.GetCoeffDataFrame() \
                                                                               for sig in signal_array])[0] \
                                                         )
    initial_average = PreliminaryAverage(signals)

    # repeatedly calculate because the average changes with better alignment
    working_mean = None
    for it in range(n_iterations):

        # take the initial
        if working_mean is None:
            working_mean = initial_average

        # superimpose all traces to the current average
        for sig in signals:
            alignment = Procrustes(sig, working_mean, compute_distance = False)
            alignment.ApplyTo(sig, skip_scale = skip_scale)

        # get new average trace
        new_mean = PreliminaryAverage(signals)
        
        # align new mean trace back to previous one
        # (otherwise there is a drift in the traces due to rounding)
        Procrustes(new_mean, working_mean, compute_distance = False).ApplyTo(new_mean, skip_scale = skip_scale)
        working_mean = new_mean

    if post_align:
        for sig in signals_raw:
            Procrustes(sig, working_mean, compute_distance = False).ApplyTo(sig, skip_scale = False)
        
    return working_mean

    

For testing the averaging process, some signals are generated with a random component to their coefficients. These are then averaged, with and without superimposition.

In [21]:
### testing
NP.random.seed(123)
n_signals = 5
noise = 0.02

signals = []
for nr in range(n_signals):
    sig = fsd.GetCoeffDataFrame().copy()
    sig.loc[:,:] += NP.random.normal(loc = 0.0, scale = noise, size = sig.shape)
    sig.loc[0,'im'] = 0
    
    sig = FourierSignal.FromDataFrame(sig, label = 'sig%i' % (nr)) 
    Shift(sig, NP.random.uniform(-0.1, 0.1, 1)[0])
    Scale(sig, NP.random.uniform(0.95, 1.05, 1)[0])
    Rotate(sig, NP.random.uniform(-0.1, 0.1, 1)[0])
    
    signals.append(sig)
    
    
### colors
lm = NP.arange(len(signals))
colormap = CM.ScalarMappable(norm = MPC.Normalize(vmin = lm.min(), vmax = lm.max()), cmap = MPP.get_cmap('Accent') )
colors = {sig.label: colormap.to_rgba(nr) for nr, sig in enumerate(signals)}

    
### plot for comparison
fig, signal_domain, frequency_domain = MakeSignalFigure()
fig.suptitle('signals before superimposition')

for sig in signals:
    signal_domain.plot(time, sig.Reconstruct(time), ls = '-', lw = 1, color = colors[sig.label], label = sig.label)
    frequency_domain.scatter(sig[:, 're'], sig[:, 'im'], s = 50, marker = 'o', color = colors[sig.label])


unaligned_average = FourierSignal.FromDataFrame( \
                                      AverageCoefficients([sig.GetCoeffDataFrame() \
                                                           for sig in signals])[0] \
                                     )
signal_domain.plot(time, unaligned_average.Reconstruct(time), ls = '--', lw = 2, color = 'r', label = 'unaligned average')
frequency_domain.scatter(unaligned_average[:, 're'], unaligned_average[:, 'im'], s = 50, marker = '+', color = 'r')
    
average_signal = ProcrustesAverage(signals, n_iterations = 5, skip_scale = True, post_align = False)
signal_domain.plot(time, average_signal.Reconstruct(time), ls = '-', lw = 2, color = 'k', label = 'average of aligned signals')
frequency_domain.scatter(average_signal[:, 're'], average_signal[:, 'im'], s = 50, marker = 'x', color = 'k')

EqualLimits(frequency_domain)
signal_domain.legend()
MPP.show();

Clearly, the alignment affects the averaging, although the artificial signals do not differ greatly in shape.

How exactly they differ can be visualized by actually applying the superimpositions, and aligning the original signals to their mean. This is, for example, useful for group averaging and subsequent comparison among groups.

In [22]:
procrustesses = {} # stores the procrustes superimpositions for below.
for sig in signals:
    proc = Procrustes(sig, average_signal, compute_distance = True)
    proc.ApplyTo(sig, skip_scale = False)
    procrustesses[sig.label] = proc
    
### plot for comparison
fig, signal_domain, frequency_domain = MakeSignalFigure()
fig.suptitle('signals after superimposition')

for sig in signals:
    signal_domain.plot(time, sig.Reconstruct(time), ls = '-', lw = 1, color = colors[sig.label], label = sig.label)
    frequency_domain.scatter(sig[:, 're'], sig[:, 'im'], s = 50, marker = 'o', color = colors[sig.label])


signal_domain.plot(time, average_signal.Reconstruct(time), ls = '-', lw = 2, color = 'k', label = 'mean')
frequency_domain.scatter(average_signal[:, 're'], average_signal[:, 'im'], s = 50, marker = 'x', color = 'k')

EqualLimits(frequency_domain)
signal_domain.legend()
MPP.show();

Difference Components

With the methods shown above, it is possible to separate differences between signals into affine components (mean, amplitude, phase) and non-affine residual (i.e. shape). This is used, for example, to generate the affine covariates in the main text, Table 1.

In [23]:
differences = PD.DataFrame.from_dict({idx: {**proc}  \
                                      for idx, proc in procrustesses.items()}).T
print (differences)
      translation  scaling  rotation  residual_euclid
sig0         0.05     0.84      0.04             0.31
sig1        -0.03     0.96     -0.03             0.37
sig2        -0.06     1.09     -0.01             0.32
sig3         0.00     0.87     -0.04             0.35
sig4         0.04     1.03      0.04             0.35

Thank you for reading!

Questions and feedback are welcome (falkmielke.biology@mailbox.org).

$\uparrow$