Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

1. Introduction

Feature engineering is the process of transforming raw data into quantities — features — that can be used in statistical analysis and prediction.

Key types of features include:

Statistical features

Statistical features summarize the distribution of data values: central tendency, dispersion, and shape.

  • Mean: average value of the data points.
  • Variance/Standard Deviation: measure of the spread of the data.
  • Skewness: asymmetry in the distribution of values.
  • Kurtosis: “tailedness” of the distribution.
  • Percentiles: threshold values for different segments of the distribution.

Temporal features

Temporal features describe the time-dependent patterns within a series.

  • Autocorrelation: correlation of a time series with a lagged version of itself.
  • Trend: long-term increase or decrease in the data.
  • Seasonality: regular patterns that repeat over a specific period.
  • Change points: locations where the statistical properties of the series change.

Below we build a synthetic daily series with a trend and a yearly cycle, remove the trend, and measure the lag-1 autocorrelation of what remains.

🖥️ Lecture slides — Session 09 (Mon Oct 19)

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)

# Create a 3-year daily time series with trend and seasonality
t = np.arange(0, 365 * 3)
seasonal_series = rng.standard_normal(3 * 365) + np.linspace(0, 10, 3 * 365) + \
    2 * np.sin(2 * np.pi * np.linspace(0, 3, 3 * 365))

# fit a trend using least squares regression
slope, intercept = np.polyfit(t, seasonal_series, 1)
trend = slope * t + intercept
# the seasonal component is what remains after removing the trend
seasonal = seasonal_series - trend
# lag-1 autocorrelation of the detrended series
autocorr_seasonal = np.corrcoef(seasonal[:-1], seasonal[1:])[0, 1]
print(f'Lag-1 autocorrelation of the detrended seasonal series: {autocorr_seasonal:.2f}')

# Plot the original time series, trend, and seasonal component
plt.figure(figsize=(10, 6))
plt.plot(seasonal_series, label='Original Time Series')
plt.plot(trend, label='Trend')
plt.plot(seasonal, label='Seasonal (detrended)')
plt.xlabel('Day')
plt.legend()
Lag-1 autocorrelation of the detrended seasonal series: 0.68
<Figure size 1000x600 with 1 Axes>

The detrended series still carries the yearly cycle, so neighboring days are similar and the lag-1 autocorrelation is high. Compare with pure white noise of the same length: white noise has no memory, so its lag-1 autocorrelation is near zero.

white_series = rng.standard_normal(3 * 365)

autocorr_white = np.corrcoef(white_series[:-1], white_series[1:])[0, 1]
print(f'Lag-1 autocorrelation of the detrended seasonal series: {autocorr_seasonal:.2f}')
print(f'Lag-1 autocorrelation of white noise:                   {autocorr_white:.2f}')
Lag-1 autocorrelation of the detrended seasonal series: 0.68
Lag-1 autocorrelation of white noise:                   -0.00

Spatial features

For geospatial data, spatial features capture the relationships between different locations in an image or dataset.

  • Texture: patterns in the local intensity variations (e.g., smooth, rough).
  • Spatial correlation: measures how similar nearby locations are in terms of intensity values.
from scipy.ndimage import gaussian_filter
from scipy.fft import fft2, ifft2, fftshift

# Generate a 2D grid (e.g., geospatial data, such as a topographic map)
n = 100  # size of the grid
x = np.linspace(0, 10, n)
y = np.linspace(0, 10, n)
X2d, Y2d = np.meshgrid(x, y)

# 2D white noise
white_noise_2d = rng.standard_normal((n, n))

# Spatially correlated noise: smooth the white noise with a Gaussian filter
spatially_correlated_noise = gaussian_filter(white_noise_2d, sigma=3)

plt.figure(figsize=(12, 6))

plt.subplot(1, 2, 1)
plt.imshow(white_noise_2d, extent=[0, 10, 0, 10], cmap='viridis')
plt.xlabel('x')
plt.ylabel('y')
plt.colorbar(label='Amplitude')
plt.title('2D White Noise')

plt.subplot(1, 2, 2)
plt.imshow(spatially_correlated_noise, extent=[0, 10, 0, 10], cmap='viridis')
plt.xlabel('x')
plt.ylabel('y')
plt.colorbar(label='Amplitude')
plt.title('2D Spatially Correlated Noise')
<Figure size 1200x600 with 4 Axes>

Spatial autocorrelation of the white noise, computed via the power spectrum (Wiener-Khinchin theorem):

# 2D Fourier transform of the white noise
fft_white_noise = fft2(white_noise_2d)

# power spectrum
power_spectrum = np.abs(fft_white_noise) ** 2

# inverse transform of the power spectrum gives the autocorrelation function
autocorrelation_white = np.real(ifft2(power_spectrum))

# shift the zero-lag component to the center
autocorrelation_white = fftshift(autocorrelation_white)

# normalize
autocorrelation_white /= autocorrelation_white.max()

Spatial autocorrelation of the correlated noise:

fft_noise = fft2(spatially_correlated_noise)
power_spectrum = np.abs(fft_noise) ** 2
autocorrelation = np.real(ifft2(power_spectrum))
autocorrelation = fftshift(autocorrelation)
autocorrelation /= autocorrelation.max()
plt.figure(figsize=(11, 6))

# White noise (unfiltered)
plt.subplot(2, 2, 1)
plt.imshow(white_noise_2d, extent=[0, 10, 0, 10], cmap='viridis')
plt.xlabel('x')
plt.ylabel('y')
plt.colorbar(label='Amplitude')
plt.title('2D White Noise')

plt.subplot(2, 2, 2)
plt.imshow(autocorrelation_white, extent=[-5, 5, -5, 5], cmap='viridis')
plt.xlabel('x')
plt.ylabel('y')
plt.colorbar(label='Amplitude')
plt.title('2D White Noise Autocorrelation')

# Spatially correlated noise
plt.subplot(2, 2, 3)
plt.imshow(spatially_correlated_noise, extent=[0, 10, 0, 10], cmap='viridis')
plt.xlabel('x')
plt.ylabel('y')
plt.colorbar(label='Amplitude')
plt.title('2D Spatially Correlated Noise')

plt.subplot(2, 2, 4)
plt.imshow(autocorrelation, extent=[-5, 5, -5, 5], cmap='viridis')
plt.xlabel('x')
plt.ylabel('y')
plt.colorbar(label='Normalized Amplitude')
plt.title('Autocorrelation Function')

plt.tight_layout()
plt.show()
<Figure size 1100x600 with 8 Axes>

Estimate the feature “correlation length” of the two images. The correlation length is often measured as the distance at which the autocorrelation function decays to 1/e of its maximum value.

center = n // 2
# correlation length for the white noise
autocorr_center = autocorrelation_white[center, center:]
distances = np.linspace(0, 5, center)
correlation_length_white = np.interp(1 / np.e, autocorr_center[::-1], distances[::-1])

# correlation length for the spatially correlated noise
autocorr_center = autocorrelation[center, center:]
correlation_length = np.interp(1 / np.e, autocorr_center[::-1], distances[::-1])

print(f'Estimated correlation length for white noise: {correlation_length_white:.2f} '
      f'and for spatially correlated noise: {correlation_length:.2f}')
Estimated correlation length for white noise: 0.07 and for spatially correlated noise: 0.61

Fractal features

Fractal features describe the self-similarity or complexity of the data across scales. A common one is the fractal dimension: a smooth curve has dimension close to 1, while a rough curve that fills more of the plane has a dimension closer to 2.

The Higuchi method estimates the fractal dimension of a time series. It measures the average “length” of the curve at coarser and coarser subsamplings; the slope of log(length) versus log(scale) gives the dimension.

def higuchi_fd(series, kmax=10):
    """Higuchi fractal dimension of a 1D time series.

    Builds kmax coarse-grained versions of the series, measures the mean
    curve length at each scale k, and fits the slope of
    log(length) vs log(1/k). Returns the estimated fractal dimension,
    between 1 (smooth) and 2 (rough).
    """
    series = np.asarray(series, dtype=float)
    npts = len(series)
    lengths = []
    for k in range(1, kmax + 1):
        lk = []
        for m in range(k):
            idx = np.arange(m, npts, k)
            if len(idx) < 2:
                continue
            dist = np.sum(np.abs(np.diff(series[idx])))
            # normalization factor for the subsampled curve
            norm = (npts - 1) / (len(idx) - 1) / k
            lk.append(dist * norm / k)
        lengths.append(np.mean(lk))
    coeffs = np.polyfit(np.log(1.0 / np.arange(1, kmax + 1)), np.log(lengths), 1)
    return coeffs[0]


print(f'Higuchi fractal dimension of white noise:         '
      f'{higuchi_fd(white_series):.2f}')
print(f'Higuchi fractal dimension of the seasonal series: '
      f'{higuchi_fd(seasonal_series):.2f}')
smooth_series = gaussian_filter(white_series, sigma=5)
print(f'Higuchi fractal dimension of smoothed noise:      '
      f'{higuchi_fd(smooth_series):.2f}')
Higuchi fractal dimension of white noise:         1.99
Higuchi fractal dimension of the seasonal series: 1.98
Higuchi fractal dimension of smoothed noise:      1.10

White noise is maximally rough, so its dimension is near 2. Smoothing lowers the dimension toward 1. The fractal dimension is a single number that captures roughness, which makes it a compact feature for classifying signals.

2. Seismic Time Series Data Example

This section demonstrates how to automatically extract features using a simple Python package, tsfel, applied to seismic waveforms recorded in the Pacific Northwest.

The miniPNW subset includes labeled seismic waveforms for events of various origins:

  • Earthquakes
  • Explosions (mostly quarry blasts)
  • Surface events (such as avalanches and landslides)
  • Sonic booms
  • Thunder

We will explore how features vary across these classes of seismic events.

Dataset citation. The data is a subset of the PNW-ML benchmark: Ni, Y., Hutko, A., Skene, F., Denolle, M., Malone, S., Bodin, P., Hartog, R., & Wright, A. (2023). Curated Pacific Northwest AI-ready Seismic Dataset. Seismica, 2(1). doi:Ni et al. (2023). The archival access path to the full dataset is seisbench.data.PNW(); here we use a small subset (“miniPNW”) hosted on a class server.

# Import modules for seismic data and feature extraction
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
import scipy
import scipy.stats as st
import os
import urllib.request
import h5py  # for reading .h5 files

We download two files from the class storage into ./data/: the waveforms as an HDF5 file and their associated metadata as a CSV file. The metadata file is small. The waveform file is large (about 1 GB), so we guard its download: if it fails, the notebook prints a message and skips the waveform-dependent cells.

os.makedirs('data', exist_ok=True)

base_url = 'https://dasway.ess.washington.edu/shared/niyiyu/PNW-ML'
metadata_path = './data/miniPNW_metadata.csv'
waveform_path = './data/miniPNW_waveforms.hdf5'

# metadata: small CSV, always download if missing
if not os.path.exists(metadata_path):
    urllib.request.urlretrieve(f'{base_url}/miniPNW_metadata.csv', metadata_path)
print(f'Metadata file present: {os.path.exists(metadata_path)}')

# waveforms: large HDF5, guarded download
if not os.path.exists(waveform_path):
    try:
        print('Downloading waveform file (large, be patient)...')
        urllib.request.urlretrieve(f'{base_url}/miniPNW_waveforms.hdf5',
                                   waveform_path)
    except Exception as e:
        if os.path.exists(waveform_path):
            os.remove(waveform_path)  # remove partial file
        raise RuntimeError(
            'Could not download miniPNW_waveforms.hdf5 from '
            f'{base_url}. Check your network connection, or place the file '
            'manually in ./data/. Original error: ' + repr(e)) from e
print(f'Waveform file present: {os.path.exists(waveform_path)}')
Metadata file present: True
Downloading waveform file (large, be patient)...
Waveform file present: True

Metadata

We first read the metadata and arrange it into a pandas DataFrame.

df = pd.read_csv(metadata_path)
# Display the first few rows of the DataFrame
df.head()
Loading...

The nature of the event source is stored in one of the metadata attributes.

df['source_type'].unique()
<ArrowStringArray> ['earthquake', 'explosion', 'sonic_boom', 'thunder', 'surface_event'] Length: 5, dtype: str

Assume that we are exploring features to classify the waveforms into the categories of event types. We take the source_type attribute as the label.

labels = df['source_type']
print(labels.value_counts())
source_type
earthquake       500
explosion        500
surface_event    500
sonic_boom       126
thunder           94
Name: count, dtype: int64

How many seismic waveforms are there in each category?

counts = labels.value_counts()
fractions = counts / counts.sum()
for name, count in counts.items():
    print(f'{name:>15s}: {count:5d} waveforms ({100 * fractions[name]:.1f}%)')
     earthquake:   500 waveforms (29.1%)
      explosion:   500 waveforms (29.1%)
  surface_event:   500 waveforms (29.1%)
     sonic_boom:   126 waveforms (7.3%)
        thunder:    94 waveforms (5.5%)

Would you say that this is a balanced dataset with respect to the classes of interest? Class imbalance matters: a classifier trained on this set will see many more of the dominant class, and accuracy alone becomes a misleading score.

counts.plot(kind='bar')
plt.ylabel('Number of waveforms')
plt.title('Class balance of the miniPNW dataset')
plt.tight_layout()
<Figure size 640x480 with 1 Axes>

Waveform data

Now we read the waveform data. It is stored in an HDF5 file under a finite number of groups. Each group has an array of datasets that correspond to the waveforms. To link the metadata to the waveform files, the key trace_name has the dataset ID. The address is labeled as follows:

bucketX$i,:3,:n

where X is the HDF5 group number and i is the index. The file typically has 3 waveforms, one from each direction of ground motion: N, E, Z. In the following, we focus on the vertical (Z) waveforms.

All cells that need the waveform file are gated behind a file-existence check, so the notebook still runs top to bottom if the download failed.

chan_list = ['N', 'E', 'Z']

if os.path.exists(waveform_path):
    f = h5py.File(waveform_path, 'r')
else:
    print('Waveform file not found: skipping waveform cells.')

Below, a function to read a waveform out of the file.

def read_data(tn, f):
    """
    Read the waveform data from the .h5 file.
    tn: trace_name of the waveform
    f: open h5py file object
    """
    bucket, narray = tn.split('$')  # split trace_name into bucket and indices
    x, y, z = [int(i) for i in narray.split(',:')]
    data = f['/data/%s' % bucket][x, :y, :z]  # read the data as a 2D array
    return data

The trace name is stored as a data attribute in the metadata.

trace_names = list(df['trace_name'])
trace_names[0]
'bucket1$0,:3,:15001'
if os.path.exists(waveform_path):
    example_waveform = read_data(trace_names[530], f)
    print(f'The first dimension of the data is {example_waveform.shape[0]}')
    print(f'The second dimension of the data is {example_waveform.shape[1]}')

    # the time vector goes from -50 to 100 s around the pick, at 100 Hz
    t = np.linspace(-50, 100, example_waveform.shape[1])

    # plot an example of the data in a 3-row subplot
    fig, ax = plt.subplots(3, 1, figsize=(10, 6))
    for i in range(3):
        ax[i].plot(t, example_waveform[i, :])
        ax[i].set_title(f'Channel {chan_list[i]}')
        ax[i].set_xlabel('Time (s)')
        ax[i].set_ylabel('Amplitude')
        ax[i].grid(True)
        ax[i].set_xlim(-50, 100)
    plt.tight_layout()
else:
    print('Waveform file not found: skipping waveform plot.')
The first dimension of the data is 3
The second dimension of the data is 15001
<Figure size 1000x600 with 3 Axes>

We extract the Z component of every waveform and stack them into a single array.

if os.path.exists(waveform_path):
    nt = example_waveform.shape[-1]
    ndata = len(labels)
    Z = np.zeros(shape=(ndata, nt))
    for i in range(ndata):
        Z[i, :] = read_data(df.iloc[i]['trace_name'], f)[2, :nt]
    print(f'We have a total of {Z.shape[0]} data samples and each has '
          f'{Z.shape[1]} data points')
else:
    print('Waveform file not found: skipping waveform stacking.')
We have a total of 1720 data samples and each has 15001 data points

Automatic feature extraction with tsfel

Now we have data and its attributes, in particular the label as source type. We are going to extract features automatically with tsfel and explore how they vary across classes.

tsfel organizes features by domain (statistical, temporal, spectral). We load the default configuration:

import tsfel

cfg = tsfel.get_features_by_domain()
print(list(cfg.keys()))
/home/runner/work/mlgeo-book/mlgeo-book/.pixi/envs/default/lib/python3.12/site-packages/tsfel/feature_extraction/calc_features.py:195: SyntaxWarning: invalid escape sequence '\*'
  \**kwargs:
['spectral', 'statistical', 'temporal', 'fractal']

tsfel takes a 1D array and the sampling rate, and returns a one-row DataFrame of features. We wrap the extraction into a function that loops over a set of waveforms, attaches the label, and cleans up the column names (tsfel prefixes every column with 0_).

Note on runtime: extracting the full feature set for every waveform in miniPNW takes too long for class. We cap the extraction to a random subset of about 200 waveforms. Feel free to raise the cap outside of class.

def calculate_features(Z, indices, df, cfg, fs=100.0):
    """
    Calculate tsfel features for a subset of waveforms.
    Z: 2D array of seismic data (n_waveforms, n_samples)
    indices: which rows of Z to process
    df: metadata DataFrame (for the source_type label)
    cfg: tsfel feature configuration

    Returns:
    X: DataFrame of features, one row per waveform, with a source_type column
    """
    rows = []
    for count, i in enumerate(indices):
        if count % 20 == 0:
            print(f'Extracting features from sample {count}/{len(indices)}')
        Xi = tsfel.time_series_features_extractor(cfg, Z[i, :], fs=fs, verbose=0)
        Xi['source_type'] = df.iloc[i]['source_type']
        rows.append(Xi)
    X = pd.concat(rows, axis=0, ignore_index=True)
    # remove the 0_ prefix that tsfel adds to every column name
    X.columns = X.columns.str.removeprefix('0_')
    return X
if os.path.exists(waveform_path):
    import time
    n_subset = min(200, ndata)
    subset_rng = np.random.default_rng(42)
    subset_idx = subset_rng.choice(ndata, size=n_subset, replace=False)

    start = time.time()
    X = calculate_features(Z, subset_idx, df, cfg)
    end = time.time()
    print(f'Time taken to calculate features for {n_subset} waveforms: '
          f'{end - start:.2f} seconds')
else:
    print('Waveform file not found: skipping feature extraction.')
Extracting features from sample 0/200
Extracting features from sample 20/200
Extracting features from sample 40/200
Extracting features from sample 60/200
Extracting features from sample 80/200
Extracting features from sample 100/200
Extracting features from sample 120/200
Extracting features from sample 140/200
Extracting features from sample 160/200
Extracting features from sample 180/200
Time taken to calculate features for 200 waveforms: 30.43 seconds
if os.path.exists(waveform_path):
    display(X.head())
else:
    print('Waveform file not found: no features to show.')
Loading...

New DataFrame clean up

Removing the samples with NaN or infinity.

if os.path.exists(waveform_path):
    print(f'no of samples in the dataframe: {X.shape[0]}')
    # Replace infinities with NaN
    new_X = X.replace([np.inf, -np.inf], np.nan, inplace=False)
    # Drop rows with NaN values
    new_X = new_X.dropna(inplace=False)
    print(f'no of samples in the new dataframe: {new_X.shape[0]}')
else:
    print('Waveform file not found: skipping cleanup.')
no of samples in the dataframe: 200
no of samples in the new dataframe: 200

Explore correlation among features

if os.path.exists(waveform_path):
    import seaborn as sns

    # Compute pairwise correlation of columns
    corr_matrix = new_X.drop('source_type', axis=1).corr().abs()

    # Mask the upper triangle (the matrix is symmetric)
    mask = np.triu(np.ones_like(corr_matrix, dtype=bool))

    plt.figure(figsize=(15, 10))
    sns.heatmap(corr_matrix, mask=mask, cmap='coolwarm', vmax=1, vmin=-1,
                center=0, square=True, linewidths=.5, annot=False)
    plt.show()
else:
    print('Waveform file not found: skipping correlation heatmap.')
<Figure size 1500x1000 with 2 Axes>

Blocks of highly correlated features are redundant: they carry the same information. Dimensionality reduction (next lesson) or feature selection can prune them.

Exploring the feature space for classification

Here we plot distributions of selected features among the classes. A feature is useful for classification when its distributions separate between classes.

def plot_feature_histograms(new_X, feature):
    import seaborn as sns
    # Get unique source types
    source_types = new_X['source_type'].unique()

    # colorblind-friendly palette
    colors = sns.color_palette('colorblind')

    for i, source_type in enumerate(source_types):
        # Select data for this source type
        data = new_X[new_X['source_type'] == source_type][feature]

        # Plot histogram for this source type
        plt.hist(np.log10(data), color=colors[i % len(colors)], alpha=0.5,
                 label=source_type)

    plt.title(f'np.log10({feature})')
    plt.xlabel(f'np.log10({feature})')
    plt.ylabel('Count')
    plt.legend()
    plt.show()
if os.path.exists(waveform_path):
    feature = 'Area under the curve'
    plot_feature_histograms(new_X, feature)

    feature = 'Kurtosis'
    plot_feature_histograms(new_X, feature)

    feature = 'Spectral variation'
    plot_feature_histograms(new_X, feature)
else:
    print('Waveform file not found: skipping feature histograms.')
<Figure size 640x480 with 1 Axes>
/home/runner/work/mlgeo-book/mlgeo-book/.pixi/envs/default/lib/python3.12/site-packages/pandas/core/arraylike.py:402: RuntimeWarning: invalid value encountered in log10
  result = getattr(ufunc, method)(*inputs, **kwargs)
<Figure size 640x480 with 1 Axes>
<Figure size 640x480 with 1 Axes>

Student exercise

  1. Which of the features will be best to classify among the event type classes?

Use the scaffold below. The idea: a feature separates two classes well when the difference between the class means is large compared to the spread within each class. Rank the features by that criterion.

if os.path.exists(waveform_path):
    # Answer scaffold: rank features by class separation.
    # Step 1: group the features by class.
    grouped = new_X.groupby('source_type')

    # Step 2: for each feature, compute the spread of the class means
    #         divided by the mean within-class standard deviation.
    feature_cols = new_X.columns.drop('source_type')
    class_means = grouped[feature_cols].mean()
    class_stds = grouped[feature_cols].std()
    separation = class_means.std(axis=0) / class_stds.mean(axis=0)

    # Step 3: sort and show the 10 most discriminative features.
    top10 = separation.sort_values(ascending=False).head(10)
    print(top10)

    # Step 4 (your turn): plot histograms of the top-ranked features with
    # plot_feature_histograms(new_X, feature) and check the separation by eye.
    # Step 5 (your turn): does the ranking change if you normalize each
    # feature first? Try (new_X - mean) / std before Step 2.
else:
    print('Waveform file not found: skipping student exercise scaffold.')
ECDF_9                     inf
ECDF_5                     inf
ECDF_6                     inf
ECDF_2                     inf
ECDF_4                     inf
Entropy                    inf
Spectral variation    2.033088
MFCC_0                1.909093
MFCC_3                1.392157
LPCC_11               1.376121
dtype: float64
References
  1. Ni, Y., Hutko, A., Skene, F., Denolle, M., Malone, S., Bodin, P., Hartog, R., & Wright, A. (2023). Curated Pacific Northwest AI-ready Seismic Dataset. Seismica, 2(1). 10.26443/seismica.v2i1.368