.. _numpy_scipy_interface:
Using NumPy and SciPy with MsPASS
============================================
Overview
-------------
The `NumPy `__ and `SciPy `__ packages are heavily used for scientific computing
in a wide range of fields. One of the reasons for that is performance.
Built-in Python containers are not designed for high-performance numerical
linear algebra.
NumPy provides typed multidimensional arrays and compiled numerical kernels;
SciPy builds additional scientific algorithms on that foundation. ObsPy also
uses NumPy arrays for waveform samples. As a result, an early
design constraint in MsPASS was to provide as clean an interface to
NumPy and SciPy as possible. However, because we chose to implement our own
core data objects in C++, that presents a disconnect that causes a few
anomalies in the MsPASS API. This section of the User's Manual
documents these issues and provides guidance for mixing NumPy and SciPy
algorithms with :py:class:`TimeSeries`,
:py:class:`Seismogram`, and ensemble
objects. The experimental regular-grid
:py:class:`Gather` and
:py:class:`SeismogramGather` types
use array-oriented storage directly and are outside this page's scope.
TimeSeries Data
-----------------
Recall that the MsPASS
:py:class:`TimeSeries` class represents
a single channel of fixed sample rate seismic data spanning a given time period.
As such we used the standard signal processing abstraction of storing
the data as a vector of numbers.
Every computing language that has any role in numerical computing defines
a syntax for arrays. A vector in this context is a one-dimensional array.
A Python list is not the same thing as an array in Fortran or C. Both Fortran and C define
an array as a fixed block of memory of a specified length containing
a sequence of numbers of the same type. (e.g. a C double array of length
10 is a block of :math:`8 \times 10` bytes.) NumPy performance is achieved by
redefining an array to assume the data are in a contiguous memory block and
using wrappers that allow low-level operations to actually be performed
by compiled C or FORTRAN functions. We do the same thing in MsPASS
but our data objects do not contain NumPy array classes directly. Instead,
we use the same approach to allow low-level operations to be
done in C++. Because we use the same concept (arrays are contiguous
blocks of memory) the arrays can mostly be used interchangeably, but
not always.
So what is the difference? If you execute this code fragment in MsPASS:
.. code-block:: python
import numpy as np
from mspasspy.ccore.seismic import DoubleVector, TimeSeries
x_numpy = np.zeros(100)
x_mspass = TimeSeries(100)
print("Type of numpy object=",type(x_numpy))
print("Type of mspass data vector=",type(x_mspass.data))
You should get this output::
Type of numpy object=
Type of mspass data vector=
The point is that both define two conceptually similar things that are a
vector of sample data. In this example
these are stored with the symbols ``x_numpy`` and ``x_mspass.data``.
The print statements emphasize they are
different data types. They are not, however, an apples-to-oranges
kind of comparison but more of a Jonathan versus Gala apple comparison.
Python APIs often use "duck typing": an operation accepts objects that
provide the required behavior rather than demanding one concrete class.
Because of "duck typing" we can, in most cases, interchange the use of the
two classes `ndarray` and `DoubleVector`.
For example, you can usually send ``DoubleVector`` data to a
NumPy or SciPy function that accepts array-like input. Here are a few
examples:
.. code-block:: python
import numpy as np
from scipy.signal import hilbert
# Setting data to nonzero values - not essential but otherwise
# trivial zero vector will cause problems for the log example
for i in range(x_mspass.npts):
x_mspass.data[i]=float(i+1)
x = np.log10(x_mspass.data)
y = np.sqrt(x_mspass.data)
z=np.cos(x_mspass.data)
yh = hilbert(x_mspass.data)
That works because ``DoubleVector`` is what NumPy's documentation
calls array-like. In fact, any NumPy or SciPy
function that accepts an array-like argument can generally accept the ``data``
member of a ``TimeSeries``. In reality, ``DoubleVector`` is
a C++ "vector container" that in C++ would be declared as:
.. code-block:: C
std::vector x;
In MsPASS we use stock "bindings" from pybind11 that allow us to do
this behavior. The buffer protocol also lets
``np.asarray(x_mspass.data)`` create a writable NumPy view without copying.
Mutating that view therefore mutates the ``TimeSeries`` samples; call
``np.array(x_mspass.data, copy=True)`` when isolation is required.
This works for most, but not all, vector operators that
mix ``DoubleVector`` and ``ndarray`` types. For example, with the symbols above defined
all of the following will work and do what is expected:
.. code-block:: python
z=x_numpy-x_mspass.data
z=x_mspass.data+x_numpy
x_mspass.data -= z
z=x_numpy*x_mspass.data # a peculiar numpy operation - not a dot product
x_mspass.data *= 10.0
The main thing to avoid is an assignment where the left hand side
and right hand side resolve to different types. For instance,
the following will fail with a ``TypeError`` exception:
.. code-block:: python
z=np.ones(100)
x_mspass.data = x_numpy
x_mspass.data = x_numpy + z
The solution is to construct a ``DoubleVector`` explicitly:
.. code-block:: python
x_mspass.data = DoubleVector(x_numpy)
x_mspass.data = DoubleVector(x_numpy + z)
That is necessary because the right-hand side is an ``ndarray``. The
``DoubleVector`` constructor copies and converts the values.
Be aware that if you mix types in a vector operation you can get some surprising
results. For example, the following code generates a ``TypeError``
exception:
.. code-block:: python
ts=TimeSeries(100)
z=np.ones(100)
ts.data = z + ts.data
while reversing the order of ``z`` and ``ts.data``
.. code-block:: python
ts=TimeSeries(100)
z=np.ones(100)
ts.data = ts.data + z
works and sets ``ts.data`` to the vector sum of the two symbols
on the right-hand side. The result type follows the implementation selected
by Python's binary-operator dispatch, so operand order matters. Avoid relying
on that detail; make the conversion explicit:
.. code-block:: python
ts=TimeSeries(100)
z = np.ones(100)
x = z + np.asarray(ts.data)
ts.data = DoubleVector(x)
Current ``DoubleVector`` bindings support ordinary Python slices. A slice
returns a new ``DoubleVector``:
.. code-block:: python
ts = TimeSeries(100)
x = ts.data[5:10]
assert isinstance(x, DoubleVector)
assert len(x) == 5
NumPy is still more convenient for advanced indexing. The important
invariant is that ``ts.npts`` must equal ``len(ts.data)``. Assigning a
shorter vector does not update ``npts`` automatically, so update both:
.. code-block:: python
ts = TimeSeries(100)
x = np.ones(100)[5:10]
ts.set_npts(len(x))
ts.data = DoubleVector(x)
assert ts.npts == len(ts.data) == 5
Seismogram Data
~~~~~~~~~~~~~~~~~
MsPASS uses a different data type to hold data from three-component sensors.
The motivation is described in the section titled
:ref:`Data Object Design Concepts`.
The result is that the array holding the sample data is a matrix
instead of a vector. Some implementation details of note are:
1. In MsPASS the matrix has 3 rows and ``npts`` columns. That means the
rows of the matrix can be extracted to create a ``TimeSeries``
subset of the data while the columns are single "vector" samples
of ground motion.
2. Currently MsPASS uses a simple, lightweight matrix class to store the sample
data called :py:class:`dmatrix`. A key reason we
made that choice was control over methods defined for the class. A
case in point is the ``shape`` attribute, which is important when working
with NumPy.
3. For performance ``dmatrix`` is implemented in C++. The class has methods
for standard matrix operations with Python bindings for those operators.
When working with ``Seismogram`` data,
use the native operators when possible.
4. A ``dmatrix`` stores the array data in a contiguous block of memory
in what NumPy calls Fortran order. Fortran stores a matrix with the
row index "varying fastest". Exchanging the sample data stored
this way with NumPy is like that with the ``std::vector`` used in
``TimeSeries``. ``np.asarray(seismogram.data)`` provides a writable view
of that buffer.
5. We use a variation of the same "magic" in the pybind11 binding code
that allows the ``Seismogram.data`` matrix to interact cleanly with
NumPy and SciPy functions that require a matrix. Like the scalar
case there are impedance mismatches, however, that can complicate
that exchange. We discuss these below.
Almost all of what was discussed above about using `TimeSeries`
arrays with NumPy are similar. For example, although the result is
meaningless, you can run the following code snippet with
MsPASS and it will run and produce the expected result:
.. code-block:: python
import numpy as np
from mspasspy.ccore.seismic import Seismogram
from mspasspy.ccore.utility import dmatrix
x_mspass = Seismogram(100)
# initialize the matrix to some meaningless, nonzero values
for i in range(3):
for j in range(x_mspass.npts):
x_mspass.data[i,j]=i+j+1
# these apply the math operation for each number in the data matrix
z=np.cos(x_mspass.data)
z=np.exp(x_mspass.data)
z=np.sqrt(x_mspass.data)
# matrix operation that multiplies all samples by 2.0
x_mspass.data *= 2.0
# matrix operator adding content of numpy matrix z to data matrix
y=x_mspass.data + z
# matrix operation adding y and x_mspass.data matrices
x_mspass.data += y
# Same thing done with a binary + operator
# use a copy to show more likely use
z=Seismogram(x_mspass)
x_mspass.data = z.data + y
As with ``TimeSeries``, a mismatch occurs if the operation
yields a type mismatch. For example, the following minor variant of
above will run:
.. code-block:: python
z=Seismogram(x_mspass)
y=np.ones([3,100])
x_mspass.data = z.data + y
In contrast, the following that might seem completely equivalent
raises a ``TypeError`` exception:
.. code-block:: python
z=Seismogram(x_mspass)
y=np.ones([3,100])
x_mspass.data = y + z.data
The reason is that for the right hand side resolves to a
NumPy ``ndarray`` and the left-hand side requires a ``dmatrix``.
Exactly like above, this simpler assignment will fail exactly the same way:
.. code-block:: python
x_mspass.data = y
The solution is similar to that we used above for `TimeSeries`:
.. code-block:: python
x_mspass.data = dmatrix(y)
That is, ``DoubleVector`` is replaced by ``dmatrix`` for matrix data.
``dmatrix`` also supports ordinary row and column slices through its NumPy
interface; those slices are returned as ``ndarray`` objects rather than as
another ``dmatrix``. When a result is assigned back, construct a ``dmatrix``
explicitly, require exactly three rows, and keep ``npts`` equal to the number
of columns:
.. code-block:: python
window = np.array(x_mspass.data, copy=True)[:, 10:20]
x_mspass.set_npts(window.shape[1])
x_mspass.data = dmatrix(window)
assert x_mspass.data.shape == (3, x_mspass.npts)