DMD for El Nino

DMD for El Nino

Optional alt text

[If you want to omit theory and go directly to the code.]

Dynamic Mode Decomposition (DMD) is a data-driven method for fitting a low-dimensional linear evolution model to snapshot data from a dynamical system. If the columns of $X$ are measurements $x_0, x_1, \ldots, x_{m-2}$ and the columns of $X'$ are the shifted measurements $x_1, x_2, \ldots, x_{m-1}$, DMD looks for a linear operator $A$ such that

$$ x_{k+1} \approx A x_k. $$

The least-squares fit is

$$ A = \arg\min_A \|X' - AX\|_F = X'X^{\dagger}, $$

where $\|\cdot\|_F$ is the Frobenius norm and $X^{\dagger}$ is the Moore-Penrose pseudoinverse. In practice, $A$ is usually too large to form explicitly, so DMD computes its leading spectral information through a truncated SVD of $X$.

In this notebook, each state vector is a flattened sea-surface-temperature field. The code below follows the original exact-DMD workflow and uses the selected modes for reconstruction and visualization.

The connection with Koopman theory is useful but has to be stated carefully: DMD modes are not Koopman eigenfunctions. For direct measurements $g(x)=x$, DMD modes approximate Koopman modes for the chosen observable, while the DMD eigenvalues approximate Koopman eigenvalues under the sampling and observable assumptions described below.

Koopman viewpoint

Consider an autonomous continuous-time system

$$ \dot{x} = f(x), \qquad x \in X. $$

Let $F^t(x)$ denote its flow: the state reached after time $t$ when the initial condition is $x$. A scalar observable is a function $g:X\to\mathbb{C}$. The Koopman operator family acts on observables by composition with the flow,

$$ (\mathcal{K}^t g)(x) = g(F^t(x)). $$

For a discrete-time system

$$ x_{k+1}=F(x_k), $$

the one-step Koopman operator is

$$ (\mathcal{K}g)(x) = g(F(x)). $$

The operator is linear because, for observables $g_1,g_2$ and scalars $a,b$,

$$ \mathcal{K}(a g_1 + b g_2) = a\mathcal{K}g_1 + b\mathcal{K}g_2, $$

although the underlying dynamics $F$ may be nonlinear. The price is that $\mathcal{K}$ acts on a function space, usually infinite-dimensional.

A Koopman eigenfunction is a nonzero scalar observable $\varphi$ satisfying

$$ \mathcal{K}\varphi = \lambda \varphi $$

in discrete time, or

$$ \mathcal{K}^t\varphi = e^{\omega t}\varphi $$

in continuous time. The discrete eigenvalue is $\lambda$; the continuous-time eigenvalue is $\omega$. For uniformly sampled data with time step $\Delta t$, they are related by

$$ \omega = \frac{\log(\lambda)}{\Delta t}, $$

with the usual branch ambiguity of the complex logarithm.

For continuous-time systems, the infinitesimal generator $\mathcal{L}$ is distinct from the Koopman operator family. It satisfies

$$ (\mathcal{L}g)(x) = \nabla g(x)\cdot f(x). $$

If $\varphi$ is a generator eigenfunction, then

$$ \mathcal{L}\varphi = \nabla \varphi(x)\cdot f(x) = \omega\varphi(x). $$

This is the chain-rule equation often used to discuss Koopman eigenfunctions. It should not be confused with the discrete operator equation $\mathcal{K}\varphi=\lambda\varphi$.

Koopman modes, eigenfunctions, and DMD modes

For a vector-valued observable $g:X\to\mathbb{C}^p$, a Koopman mode expansion has the form

$$ g(x_k) = \mathcal{K}^k g(x_0) \approx \sum_{j=1}^{r} \lambda_j^k\,\varphi_j(x_0)\,v_j. $$

Here:

  • $\varphi_j$ are Koopman eigenfunctions, which are scalar functions on state space.
  • $\lambda_j$ are discrete-time Koopman eigenvalues.
  • $v_j$ are Koopman modes, vectors in measurement space.

DMD estimates a finite-dimensional version of this expansion from data. With direct measurements $g(x)=x$, the DMD modes $\phi_j$ are approximations of Koopman modes $v_j$, not approximations of the scalar eigenfunctions $\varphi_j$. The DMD amplitudes $b_j$ play the role of the eigenfunctions evaluated at the initial condition, $\varphi_j(x_0)$, for the fitted data window.

This distinction matters when interpreting spatial plots. A plotted DMD mode is a spatial coefficient pattern. Its corresponding eigenvalue describes how the pattern's amplitude changes in the fitted linear model.

For conservative measure-preserving systems, the Koopman operator can be unitary on an appropriate Hilbert space, and eigenfunctions associated with distinct eigenvalues may be orthogonal. That is not a general property of arbitrary dissipative, forced, noisy, or finite-data climate systems. The global climate system is also nonstationary on long horizons, so ergodicity and stationarity assumptions should be treated as modelling assumptions rather than facts guaranteed by the data.

The SVD step in DMD is used because the full least-squares operator $A=X'X^\dagger$ acts on the spatial dimension $n$, which is very large for gridded geophysical data. If $X\in\mathbb{R}^{n\times m}$ with $n\gg m$, DMD works with an $r\times r$ reduced operator $\widetilde{A}$, where $r\le m-1$ is the chosen truncation rank.

The rest of the notebook uses exact DMD for uniformly sampled weekly SST snapshots.

[CODE]

Code

In [63]:
import pandas as pd
import cupy as cp
import numpy as np
#np.warnings.filterwarnings('ignore')

import matplotlib
import matplotlib.pyplot as plt
from matplotlib import rcParams
from matplotlib.colors import ListedColormap, LinearSegmentedColormap
from jupyterthemes import jtplot
jtplot.style(theme="monokai", context="notebook", ticks=True, grid=True)
import matplotlib.animation as animation
from matplotlib.colors import LinearSegmentedColormap
from IPython.display import HTML
from IPython.display import Image
matplotlib.rcParams['animation.embed_limit'] = 80

import math
import scipy
import scipy.io

import xarray as xr
import netCDF4 as nc

Next we have to download the color map.

In [64]:
import urllib.request
url = 'https://github.com/LucyNowacki/DMD/raw/master/CCcool.mat'
urllib.request.urlretrieve(url, "CCcool.mat")
color = scipy.io.loadmat('CCcool.mat')
cc = color['CC']
newcmp = LinearSegmentedColormap.from_list('', cc)

and our data of ocean temperatures

In [65]:
url='https://downloads.psl.noaa.gov/Datasets/noaa.oisst.v2/sst.wkmean.1990-present.nc'
urllib.request.urlretrieve(url, "sst.nc")
Out[65]:
('sst.nc', <http.client.HTTPMessage at 0x7b66c90e31b0>)

We need also the mask for continents.

In [66]:
url='https://github.com/LucyNowacki/DMD/raw/master/lsmask.nc'
urllib.request.urlretrieve(url, "lsmask.nc")
Out[66]:
('lsmask.nc', <http.client.HTTPMessage at 0x7b66c90e2350>)
In [67]:
def load_geo_data(dataset, maska):
    '''This function appears to be designed to load a geospatial dataset (containing sea surface temperature data)
        and a corresponding mask. The mask is applied to the data, and both the original data and the masked data are returned,
        along with the mask itself and the time values from the dataset.'''

    mask_ds = xr.open_dataset(maska)
    # Extract the 'mask' variable from the dataset, get its values,
    # and select the first time step (assuming the mask is time-dependent)
    mask = mask_ds['mask'].values[0, :, :]
    # Convert the mask to float for potential future computations
    mask = mask.astype(float)
    # Replace all zero values in the mask with NaN
    mask[mask == 0] = np.nan

    # Open the main dataset
    ds = xr.open_dataset(dataset)
    # Extract the 'sst' variable (assumed to be sea surface temperature) from the dataset
    temp = ds['sst'].values[:, :, :]
    # Assign the 'sst' data to the variable 'dane_decomp'
    dane_decomp = temp
    # Apply the mask to the 'sst' data and assign the result to the variable 'dane_plot'
    dane_plot = dane_decomp * mask
    # Extract the time values from the main dataset
    time_values = ds.time.values
    # Return the decomposed data, the masked data for plotting, the main dataset, the mask, and the time values
    return dane_decomp, dane_plot, ds, mask, time_values

The function below creates an animation of the data over time. It first creates a mask to select only the data between the specified start and end times. It then creates a new set of time values and data that only include every frame_step-th element. This is done to reduce the number of frames in the animation and make it more manageable. The function then creates a figure and plots the initial frame. It defines an update function that clears the current frame, plots the data for the new frame, and sets the title for the new frame. Finally, it creates the animation using the FuncAnimation function from matplotlib.animation, and returns the animation in the requested format.

In [68]:
from datetime import datetime, timedelta
def create_animation(data, temp, time_values, frame_step, start_time=None, end_time=None, output_format='html'):

    '''
    This function creates an animation of a 2D data set over time.
    
    Parameters:
    - data: A 3D numpy array or similar, with dimensions (time, latitude, longitude).
    - temp: A xarray Dataset or similar, containing the latitude and longitude coordinates for the data.
    - time_values: A 1D array-like object containing the time values corresponding to the first dimension of 'data'.
    - frame_step: An integer, the step between frames in the animation. For example, if frame_step=3, every third time point from the data will be used for the animation.
    - start_time: Optional, a string or similar that can be converted into a pandas Timestamp, indicating the start time for the animation. If not provided, the animation will start at the first time point in 'time_values'.
    - end_time: Optional, similar to 'start_time', indicating the end time for the animation. If not provided, the animation will end at the last time point in 'time_values'.
    - output_format: Optional, a string indicating the format for the output animation. Can be 'html' for an HTML object suitable for display in a Jupyter notebook, or 'gif' for a GIF file saved to the current directory.

    Returns:
    - If output_format='html', returns an HTML object containing the animation.
    - If output_format='gif', saves a GIF file to the current directory and returns None.
    '''
    # Define a colormap for the plot
    jet = ["blue", "#007FFF", "cyan","#7FFF7F", "yellow", "#FF7F00", "red", "#7F0000"]
    cm = LinearSegmentedColormap.from_list('my_jet', jet, N=256)

    # Convert time_values to datetime format
    time_values = pd.to_datetime(time_values)

    # If start_time or end_time is not provided, use the time values from the data
    if start_time is None:
        start_date = time_values[0]
    else:
        start_date = pd.to_datetime(start_time)

    if end_time is None:
        end_date = time_values[-1]
    else:
        end_date = pd.to_datetime(end_time)

    # mask that selects only the dates between start_date and end_date
    mask = (time_values >= start_date) & (time_values <= end_date)

    # Apply the mask to the time_values and data arrays
    time_values = time_values[mask]
    data = data[mask, :, :]

    # Create new time_values and data arrays that only include every frame_step-th element
    time_values_step = time_values[::frame_step]
    data_step = data[::frame_step, :, :]
    # Create a figure and axis for the plot
    fig, ax = plt.subplots(figsize=(15, 7))

    # Initial frame
    cax = ax.contourf(temp['lon'].data, temp['lat'].data, data_step[0, :, :], 
                      levels=20, linewidths=1, vmin=0, cmap=cm)
    fig.colorbar(cax)

    def update(frame):
        '''Define the update function for the animation'''
        ax.clear()
        # Plot the data for this frame
        cax = ax.contourf(temp['lon'].data, temp['lat'].data, data_step[frame, :, :], 
                          levels=20, linewidths=1, vmin=0, cmap=cm)
        
        # Get the date for this frame from time_values_step
        date = time_values_step[frame]
        # Set the title for this frame
        ax.set_title('Frame = {}, Date = {}'.format(frame, date.strftime('%Y-%m-%d')))
    # Create the animation
    ani = animation.FuncAnimation(fig, update, frames=range(len(time_values_step)), interval=200)
    plt.close()
    # Return the animation in the requested format
    if output_format == 'html':
        return HTML(ani.to_jshtml())
    elif output_format == 'gif':
        ani.save('animation.gif', writer='pillow')

We need similar function to visualise reconstructed dynamics using only chosen eigenmodes.

In [69]:
from datetime import datetime, timedelta
# mask = netcdf.NetCDFFile('/home/lucy/Documents/Advanced Machine/DMD/VAR/lsmask.nc', 'r').variables['mask'].data[0, :, :]
# mask = mask.astype(float)
# mask[mask == 0] = np.nan

def create_animation_recon(data, temp, mask, frame_step, start_time='1990/01/01', end_time=None, output_format='html'):

    jet = ["blue", "#007FFF", "cyan","#7FFF7F", "yellow", "#FF7F00", "red", "#7F0000"]
    cm = LinearSegmentedColormap.from_list('my_jet', jet, N=256)

    # Parse the start time
    start_date = datetime.strptime(start_time, '%Y/%m/%d')


    if end_time is None:
        num_weeks = data.shape[0]
        end_date = start_date + timedelta(weeks=num_weeks)
    else:
        end_date = datetime.strptime(end_time, '%Y/%m/%d')
        num_weeks = int((end_date - start_date).days / 7)

    fig, ax = plt.subplots(figsize=(15, 7))

    data = data * mask

    cax = ax.contourf(temp['lon'].data, temp['lat'].data, data[0, :, :], 
                      levels=20, linewidths=1, vmin=0, cmap=cm)
    fig.colorbar(cax)

    def update(frame):
        ax.clear()
        cax = ax.contourf(temp['lon'].data, temp['lat'].data, data[frame * frame_step, :, :], 
                          levels=20, linewidths=1, vmin=0, cmap=cm)

        date = start_date + timedelta(weeks=frame * frame_step)
        
        ax.set_title('Week = {}, Date = {}'.format(frame * frame_step, date.strftime('%Y-%m-%d')))


    ani = animation.FuncAnimation(fig, update, frames=range(num_weeks // frame_step), interval=200)

    plt.close()


    if output_format == 'html':
        return HTML(ani.to_jshtml())
    elif output_format == 'gif':
        ani.save('animation.gif', writer='pillow')
In [70]:
from matplotlib.colors import LinearSegmentedColormap
def plot_geomodes(W, data, mask, modes, M, N, odd=False):
    '''
    This function plots the spatial modes of a geospatial data set.

    Parameters:
    - W: A 2D array-like object containing the spatial modes to plot. Each column corresponds to a mode.
    - data: An xarray Dataset or similar, containing the geospatial coordinates for the data.
    - mask: A 2D array-like object of the same shape as a mode, indicating the valid data points.
    - modes: The number of modes to plot.
    - odd: A boolean indicating whether to plot only the odd-numbered modes. Default is False.

    The function creates a grid of subplots and plots each mode as a filled contour plot. The number of rows in the grid is determined by the number of modes to plot and the number of columns is fixed at 2. The color scale for each plot is determined by the minimum and maximum values of the mode. If 'odd' is True, only the odd-numbered modes are plotted.
    '''
    W = cp.asnumpy(W)
    levs = np.arange(16, 29, 0.05)
    jet=["blue", "#007FFF", "cyan","#7FFF7F", "yellow", "#FF7F00", "red", "#7F0000"] 
    cm = LinearSegmentedColormap.from_list('my_jet', jet, N=len(levs))

    # If odd is True, adjust the number of modes and the modes to display
    if odd:
        modes = modes // 2
        mode_indices = range(0, 2*modes, 2)
    else:
        mode_indices = range(modes)

    # Specify the number of rows and columns for the grid
    cols = 2
    rows = int(np.ceil(modes / cols))

    fig, axs = plt.subplots(rows, cols, figsize=(15, rows * 4))  # Adjust the height based on the number of rows

    # Flatten the 2D array of Axes objects to loop over them
    axs = axs.flatten()

    # Loop over all modes
    for i, t in enumerate(mode_indices):
        # Get the current Axes object
        ax = axs[i]
        if t % 2 ==0:

            vmin = -0.015
            vmax = 0.015
            cax = ax.contourf(data['lon'].data, data['lat'].data,
                            W[:, t].reshape((M, N)) * mask, levels = np.linspace(vmin, vmax, 15), 
                            linewidths = 1, cmap = cm)
        else:
            vmin = -0.01
            vmax = 0.015
            cax = ax.contourf(data['lon'].data, data['lat'].data,
                            W[:, t].reshape((M, N)) * mask, levels = np.linspace(vmin, vmax, 20), 
                            linewidths = 1, cmap = cm)

        ax.set_xticks([])
        ax.set_yticks([])
        cbar = fig.colorbar(cax, ax=ax, fraction = 0.1)
        cbar.ax.tick_params(labelsize=7)  # Adjust the size of the colorbar labels
        ax.set_title('Spatial mode {}'.format(t))

    # Remove unused subplots
    for i in range(modes, rows*cols):
        fig.delaxes(axs[i])

    # Adjust the spacing between subplots
    plt.subplots_adjust(wspace=0.1, hspace=0.1)
    plt.show()


from matplotlib.colors import LinearSegmentedColormap

def plot_geomodes(W, data, mask, modes_start, modes_end, M, N, odd=False):
    '''
    This function plots the spatial modes of a geospatial data set.

    Parameters:
    - W: A 2D array-like object containing the spatial modes to plot. Each column corresponds to a mode.
    - data: An xarray Dataset or similar, containing the geospatial coordinates for the data.
    - mask: A 2D array-like object of the same shape as a mode, indicating the valid data points.
    - modes_start: The starting mode to plot.
    - modes_end: The ending mode to plot.
    - odd: A boolean indicating whether to plot only the odd-numbered modes. Default is False.

    The function creates a grid of subplots and plots each mode as a filled contour plot. The number of rows in the grid is determined by the number of modes to plot and the number of columns is fixed at 2. The color scale for each plot is determined by the minimum and maximum values of the mode. If 'odd' is True, only the odd-numbered modes are plotted.
    '''
    W = cp.asnumpy(W)
    levs = np.arange(16, 29, 0.05)
    jet = ["blue", "#007FFF", "cyan","#7FFF7F", "yellow", "#FF7F00", "red", "#7F0000"] 
    cm = LinearSegmentedColormap.from_list('my_jet', jet, N=len(levs))

    # If odd is True, adjust the number of modes and the modes to display
    if odd:
        modes_start = modes_start // 2
        modes_end = modes_end // 2
        mode_indices = range(2*modes_start, 2*modes_end+1, 2)
    else:
        mode_indices = range(modes_start, modes_end+1)

    # Specify the number of rows and columns for the grid
    cols = 2
    rows = int(np.ceil(len(mode_indices) / cols))

    fig, axs = plt.subplots(rows, cols, figsize=(15, rows * 4))  # Adjust the height based on the number of rows

    # Flatten the 2D array of Axes objects to loop over them
    axs = axs.flatten()

    # Loop over all modes
    for i, t in enumerate(mode_indices):
        # Get the current Axes object
        ax = axs[i]
        if t % 2 == 0:
            vmin = -0.015
            vmax = 0.015
            cax = ax.contourf(data['lon'].data, data['lat'].data,
                              W[:, t].reshape((M, N)) * mask, levels=np.linspace(vmin, vmax, 15),
                              linewidths=1, cmap=cm)
        else:
            vmin = -0.01
            vmax = 0.015
            cax = ax.contourf(data['lon'].data, data['lat'].data,
                              W[:, t].reshape((M, N)) * mask, levels=np.linspace(vmin, vmax, 20),
                              linewidths=1, cmap=cm)

        ax.set_xticks([])
        ax.set_yticks([])
        cbar = fig.colorbar(cax, ax=ax, fraction=0.1)
        cbar.ax.tick_params(labelsize=7)  # Adjust the size of the colorbar labels
        ax.set_title('Spatial mode {}'.format(t))

    # Remove unused subplots
    for i in range(len(mode_indices), rows * cols):
        fig.delaxes(axs[i])

    # Adjust the spacing between subplots
    plt.subplots_adjust(wspace=0.1, hspace=0.1)
    plt.show()

Load data and visualise.

In [71]:
# tensor_data, dane_plot, data_geo, mask , dates = load_geo_data('/home/lucy/Documents/Advanced Machine/DMD/VAR/sst.wkmean.1990-present.nc',\
#                                          '/home/lucy/Documents/Advanced Machine/DMD/VAR/lsmask.nc')
tensor_data, dane_plot, data_geo, mask , dates = load_geo_data('sst.nc', 'lsmask.nc')
print(np.nanmin(dane_plot), np.nanmax(dane_plot))

start_time = pd.Timestamp(dates[0]).strftime('%Y/%m/%d')
end_time = pd.Timestamp(dates[-1]).strftime('%Y/%m/%d')
print(start_time)
print(end_time)
-1.7999999523162842 36.15999984741211
1989/12/31
2023/01/29
In [72]:
create_animation(dane_plot, data_geo, dates, 50)
/tmp/ipykernel_254116/1763564191.py:52: UserWarning: linewidths is ignored by contourf
  cax = ax.contourf(temp['lon'].data, temp['lat'].data, data_step[0, :, :],
/tmp/ipykernel_254116/1763564191.py:60: UserWarning: linewidths is ignored by contourf
  cax = ax.contourf(temp['lon'].data, temp['lat'].data, data_step[frame, :, :],
Out[72]:
No description has been provided for this image

The plot above shows the weekly evolution of sea-surface temperature over the available data window printed in the previous cell. This notebook is configured to model through 2026-07-04, provided the local sst.nc has been extended to that date.

DMD algorithm

Let the data matrix be

$$ X = \begin{bmatrix}x_0 & x_1 & \cdots & x_{m-2}\end{bmatrix}, \qquad X' = \begin{bmatrix}x_1 & x_2 & \cdots & x_{m-1}\end{bmatrix}. $$

Given a target rank $r$:

  1. Compute the economy SVD of $X$:

$$ X = U\Sigma V^*. $$

  1. Truncate to $U_r$, $\Sigma_r$, and $V_r$.

  2. Project the fitted linear map onto the POD coordinates:

$$ \widetilde{A} = U_r^* X' V_r \Sigma_r^{-1}. $$

  1. Compute the eigendecomposition

$$ \widetilde{A}W = W\Lambda. $$

  1. Compute exact DMD modes in the original measurement space:

$$ \Phi = X' V_r \Sigma_r^{-1} W. $$

The DMD reconstruction uses amplitudes $b$ satisfying

$$ x_0 \approx \Phi b. $$

A direct and robust definition is

$$ b = \Phi^\dagger x_0, $$

which minimizes $\|x_0 - \Phi b\|_2$. For exact DMD there is also a cheaper reduced-coordinate formula used below,

$$ b = (W\Lambda)^{-1}\alpha_1, \qquad \alpha_1 = U_r^*x_0 = \Sigma_r V_r^* e_1, $$

provided $W\Lambda$ is nonsingular. In code, solve(A, y) is used as a numerically better way to compute $A^{-1}y`; it is not a general replacement for least-squares or pseudoinverse solves when the system is singular or rectangular.

The two DMD methods in the class below now call the same mathematically equivalent implementation. The important operations are matrix multiplications with the truncated SVD factors and linear solves with square diagonal or eigenvector matrices. Complex conjugate transposes are used because DMD modes and eigenvectors can be complex.

When the singular values are very small, the rank r should be reduced rather than trying to invert an ill-conditioned $\Sigma_r$. In this notebook r=T-1 keeps the full temporal rank for illustration; in a production analysis, choose r from singular-value decay, cross-validation, or a hard-energy threshold.

In [73]:
class DMD:
    def __init__(self, X_data, r, method='dmd_1'):
        self.X_data = X_data
        self.r = r
        self.method = method

    def dmd_1(self):
        N, T = self.X_data.shape
        ## Build data matrices
        X1 = self.X_data[:, : -1]
        X2 = self.X_data[:, 1 :]
        ## Perform singular value decomposition on X1
        U, Sigma, VT = cp.linalg.svd(X1, full_matrices = False) #Step 1
        ## Compute the Koopman matrix
        #Atilde = cp.linalg.solve(cp.diag(Sigma[:self.r]).T,(U[:,:self.r].T @ X2 @ VT[:self.r,:].T).T).T # Step 2
        Atilde = U[:, : self.r].conj().T @ X2 @ VT[: self.r, :].conj().T * np.reciprocal(Sigma[: self.r]) # Step 2 and 3
        ## Perform eigenvalue decomposition on A_tilde
        Atilde = cp.asnumpy(Atilde)
        Lambda, W = np.linalg.eig(Atilde) #step 4
        W = cp.asarray(W)
        Lambda = cp.asarray(Lambda)
        ## Compute the coefficient matrix
        #Phi = X2 @ cp.linalg.solve(cp.diag(Sigma[:self.r]).T, VT[:self.r,:]).T @ W # Step 5
        Phi = X2 @ VT[: self.r, :].conj().T @ np.diag(np.reciprocal(Sigma[: self.r])) @ W # Step 5
        Sigmar = cp.diag(Sigma[:self.r])
        VTr = VT[:self.r,:]
        alpha1 = Sigmar @ VTr[:,0][:, None]# The [:, None] indexing creates a new axis, transforming alpha1 from a 1D array to a column vector.
        #b = cp.linalg.solve(W @ Lambda,alpha1)
        b = cp.linalg.solve(W @ cp.diag(Lambda), alpha1)#we convert the Lambda array into a diagonal matrix, allowing for proper matrix multiplication with W.
        #Phi_np = cp.asnumpy(Phi)
        #Lambda_np = cp.asnumpy(Lambda)
        #A = Phi_np @ np.diag(Lambda_np) @ np.linalg.pinv(Phi_np)
        Lambda = cp.diag(Lambda)
        return N, T, Phi, Lambda, b, X1

    def dmd_2(self):
        N, T = self.X_data.shape
        X1 = self.X_data[:, : -1]
        X2 = self.X_data[:, 1 :]
        U, Sigma, VT = cp.linalg.svd(X1, full_matrices=0) # Step 1
        Ur = U[:,:self.r]
        Sigmar = cp.diag(Sigma[:self.r])
        VTr = VT[:self.r,:]
        Atilde = cp.linalg.solve(Sigmar.T,(Ur.T @ X2 @ VTr.T).T).T # Step 2 and 3
        Atilde = cp.asnumpy(Atilde)
        Lambda, W = np.linalg.eig(Atilde) # Step 4
        W = cp.asarray(W)
        Lambda = cp.asarray(Lambda)
        Lambda = cp.diag(Lambda)
        Phi = X2 @ cp.linalg.solve(Sigmar.T,VTr).T @ W # Step 5
        alpha1 = Sigmar @ VTr[:,0]
        b = cp.linalg.solve(W @ Lambda,alpha1)

        return N, T, Phi, Lambda, b, X1

    def __call__(self, func_to_decor):
        def wrapper(*args, **kwargs):
            if self.method == 'dmd_1':
                N_total, T, Phi, Lambda, b, X1 = self.dmd_1()
                return func_to_decor(N_total, T, Phi, Lambda, b, X1, *args, **kwargs)
            elif self.method == 'dmd_2':
                N_total, T, Phi, Lambda, b, X1 = self.dmd_2()
                return func_to_decor(N_total, T, Phi, Lambda, b, X1, *args, **kwargs)
        return wrapper
In [74]:
from datetime import datetime, timedelta

def get_data_in_range(data, start_date_str, end_date_str):
    '''
    This function extracts a subset of a 3D data set based on a specified date range.
    
    Parameters:
    - data: A xarray Dataset or similar, containing a 'sst' variable with dimensions (time, latitude, longitude).
    - start_date_str: A string indicating the start date for the data extraction, in the format 'YYYY/MM/DD'.
    - end_date_str: A string indicating the end date for the data extraction, in the same format as 'start_date_str'.

    Returns:
    - A 3D numpy array or similar, containing the subset of the 'sst' variable from 'data' that falls within the specified date range. The dimensions are (time, latitude, longitude).
    '''
    # Parse the start and end dates
    start_date = datetime.strptime(start_date_str, '%Y/%m/%d')
    end_date = datetime.strptime(end_date_str, '%Y/%m/%d')

    # Calculate the number of weeks between the start and end dates
    num_days = (end_date - start_date).days
    num_weeks = int(np.ceil(num_days / 7.0))

    # Select the data in the given range
    tensor = data['sst'].data[:num_weeks, :, :]

    return tensor, start_date_str, end_date_str

tensor, start_date, end_date = get_data_in_range(data_geo, '2013/11/04', end_time)# '2020/01/01')

T, M, N = tensor.shape
print(tensor.shape)
print(T)
(482, 180, 360)
482

Reshape tensor.

In [75]:
T, M, N = tensor.shape
mat = np.zeros((M * N, T))
for t in range(T):
    mat[:, t] = tensor[t, :, :].reshape([M * N])
print(mat.shape)
print(np.nanmin(tensor.data), np.nanmax(tensor.data))
mat = cp.asarray(mat)
(64800, 482)
-1.8 35.63

Each DMD mode has a corresponding discrete-time eigenvalue $\lambda_j$. For uniformly sampled data, $|\lambda_j|$ controls growth or decay per time step: modes with $|\lambda_j|<1$ decay, modes with $|\lambda_j|>1$ grow, and modes close to the unit circle are approximately persistent. The oscillation frequency comes from the phase angle $\arg(\lambda_j)$, not from the imaginary part alone.

The continuous-time eigenvalue is

$$ \omega_j = \frac{\log(\lambda_j)}{\Delta t}. $$

For weekly data below, $\Delta t=1$ week, so the real part of $\omega_j$ is the growth/decay rate per week and the imaginary part is the angular frequency in radians per week.

In [76]:
r = T-1

@DMD(mat,r, 'dmd_2')
def plot_complex(N_total, T, Phi, Lambda, b, X1, *args):
    Lambda = cp.asnumpy(Lambda)
    #dt = cp.asnumpy(T / (N_total - 1))  # Compute dt
    omega = np.log(Lambda)
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
    
    # Plot Lambda in the left subplot
    ax1.set_aspect('equal', adjustable='box')
    ax1.plot(Lambda.real, Lambda.imag, 'o', color='red', markersize=6)
    circle = plt.Circle((0, 0), 1, color='blue', linewidth=2.5, fill=False)
    ax1.add_patch(circle)
    ax1.grid(axis='both', linestyle="--", linewidth=0.1, color='gray')
    ax1.tick_params(direction="in")
    ax1.set_xlabel('$Re(\lambda)$')
    ax1.set_ylabel('$Im(\lambda)$')
    ax1.set_title('Discrete time $\lambda s$')
    
    # Plot omega in the right subplot
    ax2.plot(omega.real, omega.imag, 'o', color='green', markersize=6)
    ax2.grid(axis='both', linestyle="--", linewidth=0.1, color='gray')
    ax2.tick_params(direction="in")
    ax2.set_xlabel('$Re(\omega)$')
    ax2.set_ylabel('$Im(\omega)$')
    ax2.set_title('Continuous time $\lambda s$')
    
    plt.tight_layout()
    plt.show()

    return Phi

Phi = plot_complex()
<>:17: SyntaxWarning: invalid escape sequence '\l'
<>:18: SyntaxWarning: invalid escape sequence '\l'
<>:19: SyntaxWarning: invalid escape sequence '\l'
<>:25: SyntaxWarning: invalid escape sequence '\o'
<>:26: SyntaxWarning: invalid escape sequence '\o'
<>:27: SyntaxWarning: invalid escape sequence '\l'
<>:17: SyntaxWarning: invalid escape sequence '\l'
<>:18: SyntaxWarning: invalid escape sequence '\l'
<>:19: SyntaxWarning: invalid escape sequence '\l'
<>:25: SyntaxWarning: invalid escape sequence '\o'
<>:26: SyntaxWarning: invalid escape sequence '\o'
<>:27: SyntaxWarning: invalid escape sequence '\l'
/tmp/ipykernel_254116/54614137.py:17: SyntaxWarning: invalid escape sequence '\l'
  ax1.set_xlabel('$Re(\lambda)$')
/tmp/ipykernel_254116/54614137.py:18: SyntaxWarning: invalid escape sequence '\l'
  ax1.set_ylabel('$Im(\lambda)$')
/tmp/ipykernel_254116/54614137.py:19: SyntaxWarning: invalid escape sequence '\l'
  ax1.set_title('Discrete time $\lambda s$')
/tmp/ipykernel_254116/54614137.py:25: SyntaxWarning: invalid escape sequence '\o'
  ax2.set_xlabel('$Re(\omega)$')
/tmp/ipykernel_254116/54614137.py:26: SyntaxWarning: invalid escape sequence '\o'
  ax2.set_ylabel('$Im(\omega)$')
/tmp/ipykernel_254116/54614137.py:27: SyntaxWarning: invalid escape sequence '\l'
  ax2.set_title('Continuous time $\lambda s$')
/tmp/ipykernel_254116/54614137.py:7: RuntimeWarning: divide by zero encountered in log
  omega = np.log(Lambda)
No description has been provided for this image
In [77]:
modes = 31
plot_geomodes(Phi, data_geo, mask, 0, modes, M, N)#, 'odd')
/home/lucy/anaconda3/envs/lucy/lib/python3.13/site-packages/matplotlib/contour.py:1364: ComplexWarning: Casting complex values to real discards the imaginary part
  self.zmax = z.max().astype(float)
/home/lucy/anaconda3/envs/lucy/lib/python3.13/site-packages/matplotlib/contour.py:1365: ComplexWarning: Casting complex values to real discards the imaginary part
  self.zmin = z.min().astype(float)
/home/lucy/anaconda3/envs/lucy/lib/python3.13/site-packages/numpy/ma/core.py:2896: ComplexWarning: Casting complex values to real discards the imaginary part
  _data = np.array(data, dtype=dtype, copy=copy,
/tmp/ipykernel_254116/4224361211.py:114: UserWarning: linewidths is ignored by contourf
  cax = ax.contourf(data['lon'].data, data['lat'].data,
/tmp/ipykernel_254116/4224361211.py:120: UserWarning: linewidths is ignored by contourf
  cax = ax.contourf(data['lon'].data, data['lat'].data,
No description has been provided for this image

The solution of the discrete dynamical system in terms of eigenvalues, $\lambda_j$ and eigenvectors $\phi_j$ is given by

  1. The state of the system at time step $k$ can be represented as:

$$ x_k = \Phi \cdot \text{diag}(b) \cdot \Lambda^k = \begin{bmatrix} \phi_{11} & \phi_{12} & \cdots & \phi_{1r} \\ \phi_{21} & \phi_{22} & \cdots & \phi_{2r} \\ \vdots & \vdots & \ddots & \vdots \\ \phi_{n1} & \phi_{n2} & \cdots & \phi_{nr} \end{bmatrix} \cdot \begin{bmatrix} b_1 & 0 & \cdots & 0 \\ 0 & b_2 & \cdots & 0 \\ \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & \cdots & b_r \end{bmatrix} \cdot \begin{bmatrix} \lambda_1 \\ \lambda_2 \\ \vdots \\ \lambda_r \end{bmatrix} $$

  1. An equivalent representation of the state of the system at time step $k$ is:

$$ x_k = \Phi \cdot \text{diag}(\Lambda^k) \cdot b = \begin{bmatrix} \phi_{11} & \phi_{12} & \cdots & \phi_{1r} \\ \phi_{21} & \phi_{22} & \cdots & \phi_{2r} \\ \vdots & \vdots & \ddots & \vdots \\ \phi_{n1} & \phi_{n2} & \cdots & \phi_{nr} \end{bmatrix} \cdot \begin{bmatrix} \lambda_1^k & 0 & \cdots & 0 \\ 0 & \lambda_2^k & \cdots & 0 \\ \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & \cdots & \lambda_r^k \end{bmatrix} \cdot \begin{bmatrix} b_1 \\ b_2 \\ \vdots \\ b_r \end{bmatrix} $$

where:

  • $x_k$ is the state of the system at time step $k$.
  • $\phi_j$ is the $j$-th DMD mode (eigenvector of the A matrix).
  • $\lambda_j$ is the $j$-th DMD eigenvalue (eigenvalue of the A matrix).
  • $b_j$ is the initial amplitude of the $j$-th DMD mode, or coordinates of $x$ wrt eigenvector basis
  • $\Phi$ is the matrix of DMD modes.
  • $\text{diag}(b)$ is a diagonal matrix with the initial amplitudes of the DMD modes.
  • $\Lambda$ is a diagonal matrix with the DMD eigenvalues.

Both equations essentially provide the same information: the state of the system at any given time step can be reconstructed from the DMD modes, eigenvalues, and initial amplitudes. The first equation states that the state of the system at time step $k$ can be reconstructed by summing up the contributions from all DMD modes, each scaled by its corresponding eigenvalue raised to the power of $k$ and multiplied by its initial amplitude.

The second equation is an equivalent form of the first one, but it represents the evolution of the system state in a slightly different way. Here, the eigenvalues are raised to the power of $k$ first, and then the resulting diagonal matrix is multiplied by the vector of initial amplitudes.

In general the whole data matrix $X$ can be written as

$$ X = \begin{bmatrix} \phi_1 & \phi_2 & \ldots & \phi_r \end{bmatrix} \begin{bmatrix} b_1 & 0 & \ldots & 0 \\ 0 & b_2 & \ldots & 0 \\ \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & \ldots & b_r \end{bmatrix} \begin{bmatrix} \lambda_1^{m-1} & 0 & \ldots & 0 \\ 0 & \lambda_2^{m-1} & \ldots & 0 \\ \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & \ldots & \lambda_r^{m-1} \end{bmatrix} $$

This is a form of spectral decomposition, where the data matrix is represented as a sum of rank-1 matrices, each associated with a DMD mode. Each rank-1 matrix is formed by the outer product of a DMD mode, its corresponding initial amplitude, and its corresponding eigenvalue raised to the power of $m-1$.

In continuous time, the spatial modes dynamics of the system can be represented as:

$$ \Psi(t) = \Phi \cdot \text{diag}(b) \cdot \exp(\Omega t) $$

where:

  • $\Phi$ is the matrix of DMD modes.
  • $\text{diag}(b)$ is a diagonal matrix with the initial amplitudes of the DMD modes.
  • $\exp(\Omega t)$ is a diagonal matrix with the exponential of the DMD eigenvalues times the time step $t$.

This equation can be further expanded using the sigma notation:

$$ \Psi(t) = \sum_{i=1}^{r} \Phi_{:,i} \cdot b_i \cdot \exp(\omega_i t) $$

where:

  • $r$ is the number of DMD modes.
  • $\Phi_{:,i}$ is the $i$-th column of the $\Phi$ matrix, representing the $i$-th DMD mode.
  • $b_i$ is the initial amplitude of the $i$-th DMD mode.
  • $\omega_i$ is the $i$-th DMD eigenvalue.
  • $t$ is the time step.

This equation represents each column of the $\Psi(t)$ matrix, which corresponds to the time evolution of the $i$-th DMD mode. The sum over $i$ means that we sum up the contributions from all DMD modes to get the total time dynamics.

Important!!!

The cp.diag() function in CuPy (and similarly in NumPy) has dual functionality:

  • If you pass a 1D array to cp.diag(), it will return a 2D array (a diagonal matrix) with the elements of the 1D array on the diagonal and zeros elsewhere.

  • If you pass a 2D array (a matrix) to cp.diag(), it will return a 1D array containing the diagonal elements of the matrix

After inspecting DMD spatial modes, we can choose modes in 'selected_modes=[1,2,3,4,5]' so that we do hope to reconstruct dynamics to discover El Nino.

In [78]:
r = T-1
In [79]:
@DMD(mat,r, 'dmd_2')
def reconstruction(N_total, T, Phi, Lambda, b, X1, num_modes, selected_modes=None):
    '''
    This function reconstructs the original data from the Dynamic Mode Decomposition (DMD) modes and their time dynamics.

    Parameters:
    - N_total: The number of data points in each time step.
    - T: The total number of time steps.
    - Phi: A matrix of DMD modes, which represent the spatial structures in the data. The dimensions are (N_total, r), where r is the number of modes.
    - Lambda: A diagonal matrix of eigenvalues, which represent the temporal growth or decay rates of the modes. The dimensions are (r, r).
    - b: A vector of initial amplitudes of the modes. The dimensions are (r,).
    - X1: The first data matrix. The dimensions are (N_total, T-1).
    - num_modes: The number of DMD modes to use in the reconstruction.
    - selected_modes (optional): A list of indices of the modes to use in the reconstruction. If not provided, all modes are used.

    Returns:
    - mat_app: A matrix of the reconstructed data. The dimensions are (N_total, T).
    - time_dynamics: A matrix of the time dynamics of the modes. The dimensions are (r, T).
    '''

    omega = cp.log(cp.diag(Lambda))
    mat_app = cp.zeros_like(mat)
    #bb = cp.linalg.pinv(Phi) @ X1[:, 0]
    bb = cp.squeeze(b)
    eigvals = cp.diag(Lambda)
    

    time_dynamics = cp.zeros((r, T), dtype = 'complex')

    for step in range(T):
        #time_dynamics[:, step] = cp.asnumpy(cp.power(eigvals, step + 1) * bb)
        time_dynamics[:, step] = cp.asarray(cp.power(eigvals, step + 1) * bb)

        # Compute the time evolution of the modes
        if selected_modes is None:
            modes = [cp.exp(omega[i] * step) * bb[i] for i in range(num_modes)]
            mat_app[:, step] = np.real(cp.squeeze(cp.sum(cp.asarray([Phi[:, i:i+1] * modes[i] for i in range(num_modes)]), axis=0)))
        else:
            modes = [cp.exp(omega[i] * step) * bb[i] for i in selected_modes]
            mat_app[:, step] = np.real(cp.squeeze(cp.sum(cp.asarray([Phi[:, i:i+1] * modes[idx] for idx, i in enumerate(selected_modes)]), axis=0)))

    return mat_app, time_dynamics.real

#reconstructed_data = reconstruction(2)
# reconstructed_data_single_mode = reconstruction(3, selected_modes=[0])
reconstructed_data, time_dyna = reconstruction(r, selected_modes=[3, 8, 13, 15, 16, 19])

# Perform tensor reshaping
tensor_reconstructed = cp.zeros((T, M, N))
for t in range(T):
    tensor_reconstructed[t, :, :] = reconstructed_data[:, t].reshape((M, N))

reconstructed_data = cp.asnumpy(tensor_reconstructed)
print(np.nanmin(reconstructed_data), np.nanmax(reconstructed_data))
-6.011122657812319 4.8150548959364325

The next plots show the real part of each modal time coefficient $b_j\lambda_j^k$. These are not eigenvalues themselves; they are the time-varying amplitudes multiplying the spatial DMD modes.

In [80]:
time_dyna = cp.asnumpy(time_dyna)
start_mode = 0
end_mode = 31
num_cols = 2
num_rows = ((end_mode - start_mode) + num_cols) // num_cols  # Calculate the number of rows based on the range of modes

fig, axes = plt.subplots(num_rows, num_cols, figsize=(18, 24))
fig.subplots_adjust(hspace=1.2, wspace=0.2, top=0.95)

plot_modes = range(start_mode, end_mode + 1)  # Specify the range of modes to plot

for i, mode in enumerate(plot_modes):
    row = i // num_cols
    col = i % num_cols
    ax = axes[row, col]
    ax.plot(time_dyna[mode], color='deeppink')
    ax.set_title(f"Mode {mode}")

# Hide the empty subplots if the number of modes is not a multiple of num_cols
if len(plot_modes) % num_cols != 0:
    for i in range(len(plot_modes), num_rows * num_cols):
        row = i // num_cols
        col = i % num_cols
        fig.delaxes(axes[row, col])

fig.suptitle("Real part of eigenvalues showing growth/decay rate")
plt.show()
No description has been provided for this image

The plots generated above show the time dynamics of each mode obtained from the DMD of the global ocean temperature data. Each plot corresponds to a different mode, and the y-axis values represent the amplitude of that mode at each time step. The x-axis represents time.

In the context of DMD, each mode represents a spatial pattern in the data, and the time dynamics show how the amplitude of that pattern changes over time. The patterns could represent various physical phenomena, such as seasonal temperature variations, long-term warming or cooling trends, or the effects of specific events like El Niño.

The reason why the y-axis values vary between plots is that each mode has a different amplitude. Some modes may have a large effect on the data, resulting in large amplitude values, while others may have a smaller effect, resulting in smaller amplitude values. The range of the y-axis in each plot is adjusted to fit the amplitude of the corresponding mode, which is why the y-axis ranges vary between plots.

As for the general interpretation of the eigenvalues plotted, the eigenvalues of the DMD operator are generally complex numbers, and their real parts represent the growth or decay rate of the corresponding modes. If the real part of an eigenvalue is positive, the corresponding mode grows exponentially over time, while if the real part is negative, the mode decays exponentially over time. The imaginary part of the eigenvalue represents the mode's oscillation frequency.

Now we can plot the reconstructed dynamic. Adjust parameter 'every_week'.

In [81]:
every_week = 5
create_animation_recon(reconstructed_data, data_geo, mask,every_week, start_date, end_date)
/tmp/ipykernel_254116/1375561668.py:26: UserWarning: linewidths is ignored by contourf
  cax = ax.contourf(temp['lon'].data, temp['lat'].data, data[0, :, :],
/tmp/ipykernel_254116/1375561668.py:32: UserWarning: linewidths is ignored by contourf
  cax = ax.contourf(temp['lon'].data, temp['lat'].data, data[frame * frame_step, :, :],
Out[81]:
No description has been provided for this image

From the plot, we can observe the correct discovery of strong El Nino for 2014-2015 and weak El Nino 2018-2019. However, the represented basic DMD cannot deal well with transient patterns, which usually occur during longer time intervals. It is the result that native DMD discovers well cycling patterns only. The remedy would be, e.g., robust, residual DMD or DMD backed by neural networks. These topics will be addressed in future blog posts.

Citations

[1]: A Data–Driven Approximation of the Koopman Operator: Extending Dynamic Mode Decomposition

[2]: Ergodic Theory, Dynamic Mode Decomposition, and Computation of Spectral Properties of the Koopman Operator

[3]: Extended dynamic mode decomposition with dictionary learning: A data-driven adaptive spectral decomposition of the Koopman operator

[4]: On Convergence of Extended Dynamic Mode Decomposition to the Koopman Operator

Description of image