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)
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.
This script will use several standard Python libraries.
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.
# 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
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
A lot of functions are required to facilitate the FSD process. All of them are standard maths.
We will need some functions to conveniently handle complex numbers.
Note that 1j
in numpy
is the imaginary number $i$.
# 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)
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.
### 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.
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:
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)$$### 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.
Some standard operations in polar coordinates.
# 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)] \
])
To check whether the transformation was correct, arbitrary coefficients are taken, synthethized to a signal, decomposed again, and so forth.
### 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();
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).
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.
### 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))
### 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-');
As was already indicated in the code above, a signal in frequency domain has three main transformation operations:
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:
Standardization functions transform a signal to these reference points.
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:
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
### 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.
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.
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:
### 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.
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).
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.
### 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.
Finally, all the presented operations allow for the computation of a standardized signal. This will have mean zero, amplitude of one, and phase zero.
### 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
### 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();
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").
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:
### 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();
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.
### 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.
### 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.
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();
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.
differences = PD.DataFrame.from_dict({idx: {**proc} \
for idx, proc in procrustesses.items()}).T
print (differences)
Thank you for reading!
Questions and feedback are welcome (falkmielke.biology@mailbox.org).