コンテンツへスキップ
ライブラリの全資料

金融時系列のスペクトル・ウェーブレット特徴量

ノートブック Machine Learning for Trading

サマリー

このノートブックでは、SPYの日次リターンを使い、周波数領域の分析とトレード特徴量としての限界を説明します。ウェーブレット分解は変動を徐々に遅い時間尺度の帯域に分け、スペクトル推定はリターンの変動が周波数間にどう分布するかを示します。例ではウェーブレット族を比較し、ローリング期間から因果的な特徴量を作成して、支配周期、スペクトルの広がり、低周波成分の比率、正規化スペクトルエントロピーなどの指標を論じます。

図示されたスペクトルはほぼ平坦で、指数の日次リターンに周期構造がほとんどないことを示します。変動の大部分は最も速いウェーブレット尺度にあります。全期間のウェーブレット成分は因果的ではなく、そのままモデル入力に使うと将来情報が漏れると強調しています。また、スペクトルエネルギーの合計は分散の尺度を変えたものにすぎないと指摘します。結果は1銘柄と重複期間から得られたもので、報告された関係は予測上の有意性を示す証拠ではなく、記述的なものです。期間内の構造変化によって推定値が異なるレジームを混在させる可能性もあります。

主なアイデア

  • ウェーブレット分解はリターンの変動を担う時間尺度の特定に役立ちますが、全期間の成分には将来データが使われます。
  • ローリングスペクトル計算により、モデルに使える因果的な周波数特徴量を作成できます。
  • 日次のSPYリターンには周期構造がほとんどなく、変動は速い尺度に集中しています。
  • スペクトルエネルギーの合計は期間内分散を尺度変換したもので、既存の特徴量と重複する場合があります。
  • 周波数推定は期間内の過程が安定していると仮定するため、構造変化を曖昧にすることがあります。

タグ

全文
# Spectral and Wavelet Features


# Spectral and Wavelet Features

**Chapter 9 | Section 9.2**

**Docker image**: `ml4t`

Every method so far has described a series by what it does over time. A **spectral**
method describes it by what it is made of: a sum of oscillations at different
frequencies, each with its own strength. The question changes from "where is the level
going" to "how much of the movement happens on a weekly cycle, and how much on a
quarterly one", and some things that are invisible in a price chart are obvious in that
accounting.

**Learning objectives**

- Split a return series into components at different time scales, and read off which
  scales carry most of its variation.
- Say why the standard version of that split cannot be used as a feature, and translate
  what it shows into a rolling calculation that can.
- Compute the frequency content of a moving window and turn it into columns: how much
  total movement there is, at what period the strongest oscillation sits, how spread out
  the frequency content is, and what share sits in the slowest band.
- Estimate the same frequency content more stably by averaging over overlapping
  segments, and read the result as a picture of frequency against time.
- Recognise when a spectral quantity is a rescaling of something you already have.

**Book reference**

Chapter 9, Section 9.2 (Transforming signals to uncover hidden structure).

**Prerequisites**

`01_visual_diagnostics` for stationarity, which is what makes a spectrum meaningful:
the frequency content of a series whose properties change over time is an average over
periods that do not belong together.

## Setup

```python
"""Spectral and Wavelet Features - frequency-domain feature engineering."""

import importlib
import logging
from datetime import datetime
from typing import Any, cast

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import polars as pl
from IPython.display import display
from matplotlib.colors import LinearSegmentedColormap
from ml4t.engineer.features.ml import fourier_features, rolling_entropy
from ml4t.engineer.logging import setup_logging
from scipy.signal import welch

from data import load_etfs
from utils.style import COLORS, FIGSIZE, show_with_alt

setup_logging(level=logging.ERROR)  # per-call timing notices, not results

pywt = cast(Any, importlib.import_module("pywt"))

# A sequential ramp for the one heatmap, built from palette entries so it runs light to
# dark in a single direction and survives a grayscale reduction.
SPECTRUM_CMAP = LinearSegmentedColormap.from_list(
    "ml4t_spectrum", [COLORS["bg_light"], COLORS["amber"], COLORS["copper"], COLORS["blue"]]
)
```

```python
START_DATE = "2015-01-01"
END_DATE = "2024-12-31"
```

## The series

Ten years of SPY daily returns. Returns rather than prices, because a spectrum
decomposes a series into oscillations around a constant level and a price has no
constant level to oscillate around: its spectrum is dominated by the trend and says
nothing else.

```python
etfs = load_etfs(symbols=["SPY"])
spy = etfs.select(["timestamp", "close"]).sort("timestamp")

START = datetime.strptime(START_DATE, "%Y-%m-%d")
END = datetime.strptime(END_DATE, "%Y-%m-%d")
spy = spy.filter((pl.col("timestamp") >= START) & (pl.col("timestamp") <= END))
spy = spy.with_columns(returns=pl.col("close").pct_change()).drop_nulls()

spy_pd = spy.to_pandas().set_index("timestamp")
returns = spy_pd["returns"].to_numpy(dtype=float, copy=True)
prices = spy_pd["close"].to_numpy(dtype=float, copy=True)
sessions = spy_pd.index

print(f"SPY: {len(spy_pd):,} sessions ({sessions.min().date()} to {sessions.max().date()})")
```

# Part 1: wavelets, and why they stay in the research notebook

A **wavelet decomposition** splits a series into components at doubling time scales. The
first detail component holds what changes fastest, the second holds what changes half as
fast, and so on; what is left at the end is the slowest movement of all. On daily data
with five levels the components cover roughly two to four sessions, four to eight,
eight to sixteen, sixteen to thirty-two, thirty-two to sixty-four, and everything slower.

The reason this is an analysis tool and not a feature is in how the components are built.
The discrete wavelet transform runs a filter both forward and backward over the whole
series, so the value of a component at any session depends on sessions after it. Every
component is therefore non-causal, and a model trained on one is reading its own future.
What the decomposition is for is deciding which scales are worth building causal features
at, which is what the second half of this part does.

## Three wavelet families

A **wavelet family** is the shape of the filter the transform runs. The three below
differ in length and in how symmetric they are, which trades localisation in time against
localisation in frequency: a longer filter separates frequencies more sharply and smears
a sudden event over more sessions.

```python
WAVELET_FAMILIES = {"db6": "Daubechies-6", "sym6": "Symlet-6", "coif3": "Coiflet-3"}
DEFAULT_WAVELET = "db6"
DECOMPOSITION_LEVELS = 5

display(
    pd.DataFrame(
        [
            {
                "wavelet": name,
                "code": code,
                "filter length": pywt.Wavelet(code).dec_len,
                "vanishing moments": pywt.Wavelet(code).vanishing_moments_psi,
                "symmetry": pywt.Wavelet(code).symmetry,
            }
            for code, name in WAVELET_FAMILIES.items()
        ]
    )
)
```

```python
def wavelet_decompose(signal: np.ndarray, wavelet: str, level: int) -> dict[str, np.ndarray]:
    """Split *signal* into one component per scale. Non-causal: reads the whole series."""
    coefficients = pywt.wavedec(signal, wavelet, level=level)

    components = {
        f"A{level}": pywt.upcoef("a", coefficients[0], wavelet, level=level, take=len(signal))
    }
    for i, detail in enumerate(coefficients[1:], 1):
        components[f"D{level - i + 1}"] = pywt.upcoef(
            "d", detail, wavelet, level=level - i + 1, take=len(signal)
        )
    return components


SCALE_LABELS = {
    "D1": "D1, 2 to 4 sessions",
    "D2": "D2, 4 to 8 sessions",
    "D3": "D3, 8 to 16 sessions",
    "D4": "D4, 16 to 32 sessions",
    "D5": "D5, 32 to 64 sessions",
    "A5": "A5, slower than 64 sessions",
}

decomposition = wavelet_decompose(returns, DEFAULT_WAVELET, DECOMPOSITION_LEVELS)
```

The panels below and the variance chart after them read fastest scale first, so the
scales are listed in that order explicitly. Sorting the component names alphabetically
would put the slowest one first, because `A` precedes `D`.

```python
ordered_scales = [f"D{level}" for level in range(1, DECOMPOSITION_LEVELS + 1)] + [
    f"A{DECOMPOSITION_LEVELS}"
]
fig, axes = plt.subplots(len(ordered_scales) + 1, 1, figsize=FIGSIZE["grid_3x2"], sharex=True)

axes[0].plot(sessions, returns, linewidth=0.3, alpha=0.7, color=COLORS["neutral"])
axes[0].set_title("Returns")
for ax, name in zip(axes[1:], ordered_scales):
    ax.plot(sessions, decomposition[name], linewidth=0.5, color=COLORS["blue"])
    ax.set_title(SCALE_LABELS[name])
axes[-1].set_xlabel("Session")

fig.suptitle(f"The same returns split across {DECOMPOSITION_LEVELS} doubling time scales")
show_with_alt(
    fig,
    "Seven stacked panels sharing a time axis. The top is the SPY daily return series. "
    "Below it, one panel per scale from the fastest to the slowest: the fast panels look "
    "like the return series itself and carry most of its amplitude, and each successive "
    "panel is smoother and smaller, with the slowest panel a gentle wave close to zero. "
    "Every panel but the slowest widens sharply in early 2020, and the slowest makes its "
    "largest excursion there.",
)
```

## Which scales carry the movement

The components add back to the original series, so their variances say how the total
variation of returns is distributed across time scales. This is the number the
decomposition exists to produce.

```python
scale_variance = pd.DataFrame(
    [
        {
            "scale": SCALE_LABELS[name],
            "share of return variance, percent": 100
            * np.var(decomposition[name])
            / np.var(returns),
        }
        for name in ordered_scales
    ]
)
display(scale_variance)
```

```python
fig, ax = plt.subplots(figsize=FIGSIZE["single"])
ax.barh(
    scale_variance["scale"],
    scale_variance["share of return variance, percent"],
    color=COLORS["blue"],
)
ax.invert_yaxis()
ax.set_xlabel("Percent of return variance")
ax.set_title("Most of the variation of daily returns is at the fastest scale")
show_with_alt(
    fig,
    "A horizontal bar chart of six wavelet scales ordered from fastest at the top to "
    "slowest at the bottom. The fastest scale's bar is much the longest and each "
    "successive bar is shorter, with the slowest two barely visible.",
)
```

## Do the families disagree

The choice of family is the first thing anyone asks about and the least consequential
decision here. Comparing the slowest and the fastest component under all three shows
where the choice can and cannot matter.

```python
fig, axes = plt.subplots(len(WAVELET_FAMILIES), 2, figsize=FIGSIZE["grid_3x2"], sharex=True)

for row, (code, name) in enumerate(WAVELET_FAMILIES.items()):
    components = wavelet_decompose(returns, code, DECOMPOSITION_LEVELS)
    axes[row, 0].plot(
        sessions, components[f"A{DECOMPOSITION_LEVELS}"], linewidth=0.8, color=COLORS["blue"]
    )
    axes[row, 0].set_title(f"{name}: slowest component")
    axes[row, 1].plot(sessions, components["D1"], linewidth=0.3, alpha=0.7, color=COLORS["copper"])
    axes[row, 1].set_title(f"{name}: fastest component")

axes[-1, 0].set_xlabel("Session")
axes[-1, 1].set_xlabel("Session")
fig.suptitle("The family barely moves the slow component")
show_with_alt(
    fig,
    "A three by two grid, one row per wavelet family. The left column shows the slowest "
    "component under each family and the three panels are almost identical. The right "
    "column shows the fastest component and the three differ visibly in their fine detail "
    "while agreeing on where the large excursions are.",
)
```

The slow components are nearly the same under all three families and the fast components
differ in their fine detail. That ordering is what to expect: the slow component averages
over hundreds of sessions, so the shape of a twelve-tap or eighteen-tap filter washes out,
while the fast component is a few sessions wide and the filter shape is most of it. The
consequential choice is the number of levels, which decides what "slow" means.

## From a scale to a feature that can be computed

Each scale corresponds to a range of periods, and a rolling window of about that length
is a causal quantity covering the same ground. The table below checks how well the cheap
causal version tracks the expensive non-causal one: for each scale, the correlation
between a rolling standard deviation of returns over the matching window and a rolling
average of the size of that scale's component.

A high correlation means the rolling statistic is picking up what the decomposition sees
at that scale, and the rolling statistic is the one a model can read.

```python
SCALE_TO_WINDOW = {"D1": 3, "D2": 5, "D3": 10, "D4": 21, "D5": 63, "A5": 126}


def trailing(values: np.ndarray, window: int, statistic) -> np.ndarray:
    """*statistic* over the *window* sessions before each session; NaN until it fits."""
    out = np.full(len(values), np.nan)
    for t in range(window, len(values)):
        out[t] = statistic(values[t - window : t])
    return out


proxy_rows = []
for scale, window in SCALE_TO_WINDOW.items():
    causal = trailing(returns, window, np.std)
    scale_size = trailing(np.abs(decomposition[scale]), window, np.mean)
    both = ~np.isnan(causal) & ~np.isnan(scale_size)
    proxy_rows.append(
        {
            "scale": scale,
            "rolling window, sessions": window,
            "correlation with the scale's size": np.corrcoef(causal[both], scale_size[both])[0, 1],
        }
    )

display(pd.DataFrame(proxy_rows))
```

# Part 2: the frequency content of a moving window

The **discrete Fourier transform** writes a finite series as a sum of sine waves at
frequencies that fit a whole number of times into its length, and returns how much of
each is present. Applied to a moving window it is causal by construction: the value at a
session uses that window and nothing after it.

Four quantities come out of each window's **power spectrum**, the strength of each
frequency:

- **spectral energy**, the total strength across all frequencies
- **dominant period**, the length of the strongest oscillation
- **spectral entropy**, how evenly the strength is spread across frequencies: high when
  every frequency contributes about equally, which is what noise looks like, and low when
  one oscillation dominates
- **low-frequency ratio**, the share of the total sitting in the two slowest oscillations
  the window can resolve

Entropy is divided by the largest value it could take for the number of frequencies in
the window, so it lands between zero and one and can be compared across window lengths.
Without that division a longer window has more frequencies to spread across and scores
higher for that reason alone.

One detail of the transform has to be handled where the power is summed. A real series
pairs every frequency with its negative, and `rfft` returns each pair once; the constant
term has no partner, and at an even window length neither does the fastest frequency. So
the fastest bin is half-weighted at even window lengths, which is what makes the total
equal the window's own energy whatever the window length is.

```python
def rolling_fft_features(
    signal: np.ndarray, window: int, target_periods: list[int]
) -> dict[str, np.ndarray]:
    """Spectral summaries of the *window* sessions before each session."""
    n = len(signal)
    names = ["spectral_energy", "dominant_period", "spectral_entropy", "low_freq_ratio"]
    out = {name: np.full(n, np.nan) for name in names}
    out.update({f"energy_period_{p}": np.full(n, np.nan) for p in target_periods})
    out["valid"] = np.zeros(n, dtype=bool)

    frequencies = np.fft.rfftfreq(window)
    # The two slowest oscillations the window resolves. Defining the band by bin count
    # rather than by a calendar period keeps it non-empty at every window length.
    slow_band = (frequencies > 0) & (frequencies < 3.0 / window)

    for t in range(window, n):
        segment = signal[t - window : t]
        power = np.abs(np.fft.rfft(segment - segment.mean())) ** 2
        if window % 2 == 0:
            power[-1] *= 0.5  # the unpaired fastest bin; see the note above the function
        total = power[1:].sum()
        if total <= 0:
            continue

        out["spectral_energy"][t] = total
        out["dominant_period"][t] = 1.0 / frequencies[np.argmax(power[1:]) + 1]

        share = power[1:] / total
        share = share[share > 0]
        out["spectral_entropy"][t] = -np.sum(share * np.log(share)) / np.log(len(power) - 1)

        out["low_freq_ratio"][t] = power[slow_band].sum() / total

        for period in target_periods:
            centre = int(np.argmin(np.abs(frequencies - 1.0 / period)))
            out[f"energy_period_{period}"][t] = power[max(1, centre - 1) : centre + 2].sum()

        out["valid"][t] = True
    return out
```

```python
FFT_WINDOW = 63  # sessions in each window: about one quarter
TARGET_PERIODS = [5, 21, 63]  # a week, a month, a quarter of sessions

fft_out = rolling_fft_features(returns, FFT_WINDOW, TARGET_PERIODS)
covered = fft_out["valid"]
```

## One of the four is not a new feature

Before reading the four columns, check what the first one is. Parseval's theorem says the
total power of a segment equals the sum of its squared deviations from its own mean, so
the spectral energy of a window is that window's variance multiplied by a constant that
depends only on the window length. It carries no frequency information at all.

The check is run at two window lengths, one odd and one even, because the identity is
where the Nyquist bin has to be handled: a real transform pairs each frequency with its
negative, and at an even window length the fastest frequency has no partner, so counting
it at full weight puts a data-dependent term into the total.

```python
parseval_rows = []
for window in [FFT_WINDOW, FFT_WINDOW + 1]:
    energy = rolling_fft_features(returns, window, TARGET_PERIODS)["spectral_energy"]
    variance = trailing(returns, window, np.var)
    usable = ~np.isnan(energy) & ~np.isnan(variance)
    ratio = energy[usable] / variance[usable]
    parseval_rows.append(
        {
            "window": window,
            "length": "odd" if window % 2 else "even",
            "correlation with the variance": np.corrcoef(energy[usable], variance[usable])[0, 1],
            "mean ratio": ratio.mean(),
            "spread of the ratio": ratio.std(),
            "half the squared window": window**2 / 2,
        }
    )
display(pd.DataFrame(parseval_rows))
```

The correlation is one at both window lengths and the ratio is the same constant, half
the squared window length, so a model given both spectral energy and rolling variance is
given the same column twice. Keep whichever is cheaper and reach
for the spectrum when the question is about *which* frequencies, which the other three
columns answer and this one cannot.

The general lesson is worth more than the instance: a transform that preserves total
energy will always hand back a rescaled variance if you ask it for a total. Check what a
derived feature reduces to before adding it to a model.

```python
fig, axes = plt.subplots(3, 1, figsize=FIGSIZE["grid_3x2"], sharex=True)

ax = axes[0]
ax.plot(sessions[covered], fft_out["dominant_period"][covered], linewidth=0.8, color=COLORS["blue"])
for period, label in [(5, "One week"), (21, "One month")]:
    ax.axhline(period, color=COLORS["negative"], linestyle="--", linewidth=0.5)
    ax.annotate(
        label,
        xy=(0.005, period),
        xycoords=("axes fraction", "data"),
        xytext=(0, 2),
        textcoords="offset points",
        fontsize=7,
        color=COLORS["negative"],
    )
ax.set_ylabel("Sessions")
ax.set_title("Dominant period: where the strongest oscillation sits")

ax = axes[1]
ax.plot(
    sessions[covered], fft_out["spectral_entropy"][covered], linewidth=0.8, color=COLORS["amber"]
)
ax.set_ylim(0, 1)
ax.set_ylabel("Share of the maximum")
ax.set_title("Spectral entropy: how evenly the power is spread")

ax = axes[2]
ax.plot(
    sessions[covered], fft_out["low_freq_ratio"][covered], linewidth=0.8, color=COLORS["copper"]
)
SLOW_BINS = 2  # the band the low-frequency ratio reads, in frequency bins
white_noise_share = SLOW_BINS / (FFT_WINDOW // 2)
ax.axhline(white_noise_share, color=COLORS["neutral"], linestyle="--", linewidth=0.6)
ax.annotate(
    f"{SLOW_BINS} of {FFT_WINDOW // 2} frequencies: the share under white noise",
    xy=(0.995, white_noise_share),
    xycoords=("axes fraction", "data"),
    xytext=(0, 4),
    textcoords="offset points",
    ha="right",
    fontsize=7,
    color=COLORS["neutral"],
)
ax.set_ylabel("Share of total power")
ax.set_xlabel("Session")
ax.set_title("Low-frequency ratio: the share in the two slowest oscillations")

fig.suptitle(f"Three spectral features over a {FFT_WINDOW}-session window")
show_with_alt(
    fig,
    "Three stacked panels sharing a time axis. The top plots the dominant period in "
    "sessions, a spiky series that mostly sits at the short end near the weekly reference "
    "line with occasional jumps to much longer periods. The middle plots the normalised "
    "spectral entropy, a high and fairly flat series close to the top of its range. The "
    "bottom plots the low-frequency ratio against a dashed reference at the share white "
    "noise would put in the same band, oscillating around and mostly below it.",
)
```

All three panels say the same thing about daily index returns, from different directions.
The entropy sits near its maximum, which means power is spread almost evenly across
frequencies; the low-frequency ratio hovers around the share white noise would put in the
same two bins; and the dominant period jumps around because with a nearly flat spectrum
the largest bin is decided by noise. That is what a spectrum of a near-white series looks
like, and it is the honest reading: these features are informative about *departures*
from that state rather than carrying a periodic structure of their own.

## Energy at calendar periods

The alternative to asking where the peak is is asking how much power sits at periods you
care about in advance. `energy_period_*` returns the raw power in the three frequency
bins around a named period, which scales with the volatility of the window like any other
power. The figure divides each by the window's total, which is what makes the three
comparable to each other and independent of the volatility level; a model reading the raw
columns has to do the same division or it is reading volatility three times.

```python
fig, ax = plt.subplots(figsize=FIGSIZE["single"])
for period, label, color in [
    (5, "Weekly", COLORS["blue"]),
    (21, "Monthly", COLORS["amber"]),
    (63, "Quarterly", COLORS["copper"]),
]:
    share = fft_out[f"energy_period_{period}"][covered] / fft_out["spectral_energy"][covered]
    ax.plot(sessions[covered], share, linewidth=0.7, color=color, label=label, alpha=0.85)
ax.set_ylabel("Share of window power")
ax.set_xlabel("Session")
ax.set_title("No calendar period holds a lasting share of the power")
ax.legend(fontsize=7)
show_with_alt(
    fig,
    "Three overlaid series showing the share of each window's power near the weekly, "
    "monthly and quarterly frequencies. All three oscillate in the same low range with no "
    "persistent separation and no trend over the decade; the weekly share reaches the "
    "highest individual peaks and the quarterly share the lowest.",
)
```

# Part 3: a steadier estimate of the same thing

A single Fourier transform of one window is a noisy estimate of the spectrum: its
variance does not fall as the window gets longer, because a longer window buys more
frequencies rather than a better estimate of each. **Welch's method** trades resolution
for stability by cutting the series into overlapping segments, transforming each, and
averaging. Fewer frequencies, each of them estimated better.

```python
WELCH_SEGMENT = 128  # sessions per segment
WELCH_OVERLAP = 64  # sessions shared between consecutive segments

welch_freqs, welch_psd = welch(returns, fs=1.0, nperseg=WELCH_SEGMENT, noverlap=WELCH_OVERLAP)
welch_periods = np.where(welch_freqs > 0, 1.0 / np.maximum(welch_freqs, 1e-12), np.inf)

fig, axes = plt.subplots(1, 2, figsize=FIGSIZE["dual_h_tall"])

ax = axes[0]
ax.semilogy(welch_freqs[1:], welch_psd[1:], linewidth=1, color=COLORS["blue"])
for frequency, label in [(1 / 5, "Weekly"), (1 / 21, "Monthly"), (1 / 63, "Quarterly")]:
    ax.axvline(frequency, color=COLORS["neutral"], linestyle="--", linewidth=0.6)
    ax.annotate(
        label,
        xy=(frequency, 0.98),
        xycoords=("data", "axes fraction"),
        rotation=90,
        fontsize=6,
        va="top",
        ha="right",
        color=COLORS["neutral"],
    )
ax.set_xlabel("Cycles per session")
ax.set_ylabel("Power, log scale")
ax.set_title("Across all frequencies")

ax = axes[1]
slow_half = slice(1, 50)
ax.plot(
    welch_periods[slow_half],
    welch_psd[slow_half],
    linewidth=1,
    marker="o",
    markersize=2,
    color=COLORS["blue"],
)
ax.set_xlabel("Period, sessions")
ax.set_ylabel("Power")
ax.set_title("The slow half, on a period axis")

fig.suptitle("The return spectrum is close to flat")
show_with_alt(
    fig,
    "Two panels of the same Welch estimate. The left plots power against frequency on a "
    "log vertical axis with dashed markers at the weekly, monthly and quarterly "
    "frequencies; the curve is close to horizontal across the whole range with no peak at "
    "any marker. The right plots the slow half against period in sessions and shows the "
    "same near-flat level with scattered small fluctuations.",
)
```

A flat spectrum is the spectrum of white noise, and that is very nearly what daily index
returns have. No calendar frequency stands out, which is a result rather than a failure:
a tradable weekly cycle in a liquid index would be arbitraged away, and finding one here
would be a reason to suspect the calculation. What does vary is the *level* of the whole
curve over time, which is volatility, and that is what the next figure shows.

## Frequency against time

Running the same estimate on a moving window and stacking the results gives a picture
with time along one axis and period along the other, shaded by how much power sits at
each combination. A vertical band means a period when everything moved more; a horizontal
band would mean a period length that was persistently strong, and there are none.

```python
PSD_WINDOW = 252  # sessions per estimate: about one year
PSD_STEP = 5  # sessions between estimates: one week
PSD_SEGMENT = 64  # sessions per segment inside each estimate; also the slowest period it resolves


def rolling_welch_psd(signal: np.ndarray, window: int, step: int, segment: int):
    """Welch estimates over successive windows; returns their end indices, frequencies, power."""
    ends, estimates, frequencies = [], [], None
    for end in range(window, len(signal), step):
        freqs, power = welch(
            signal[end - window : end],
            fs=1.0,
            nperseg=min(segment, window // 2),
            noverlap=segment // 2,
        )
        frequencies = freqs if frequencies is None else frequencies
        ends.append(end)
        estimates.append(power)
    return np.array(ends), frequencies, np.array(estimates)


psd_ends, psd_freqs, psd_matrix = rolling_welch_psd(returns, PSD_WINDOW, PSD_STEP, PSD_SEGMENT)
```

```python
drawn = psd_freqs > 0
periods = 1.0 / psd_freqs[drawn]
power = np.log10(psd_matrix[:, drawn].T)

fig, ax = plt.subplots(figsize=FIGSIZE["single_tall"])
# The colour range spans the middle of the data rather than its extremes, so the shading
# is not compressed by a handful of cells.
mesh = ax.pcolormesh(
    sessions[psd_ends],
    periods,
    power,
    cmap=SPECTRUM_CMAP,
    shading="auto",
    vmin=np.percentile(power, 2),
    vmax=np.percentile(power, 98),
)
ax.set_ylim(periods.min(), periods.max())
ax.set_ylabel("Period, sessions")
ax.set_xlabel("Session the window ends")
ax.set_title("Power moves with volatility, not with any period")
fig.colorbar(mesh, ax=ax, label="Power, log base 10")
show_with_alt(
    fig,
    "A heat map with time across the bottom and oscillation period up the side, shaded "
    "from pale for low power to dark for high. The shading is close to uniform vertically "
    "at any given time, so no period stands out. It darkens across the whole height for a "
    "wide band running from mid-2020 into 2021, and again more faintly around 2023.",
)
```

Two things about reading this picture. The dark band arrives after the event that caused
it and outlasts it: each column is an estimate over the year *ending* at that session, so
a volatile month darkens every window that still contains it, for a year afterwards. And
the slowest period drawn is set by the segment length inside each estimate rather than by
the window: averaging over 64-session segments is what buys the stability, and it is also
what makes 64 sessions the slowest oscillation any of these estimates can see.

# Part 4: the feature table

The same three columns at three window lengths. A short window resolves few frequencies
and updates quickly; a long one resolves many and responds slowly. Keeping all three lets
a model use whichever timescale carries the information rather than committing in advance.

```python
FEATURE_WINDOWS = [21, 63, 126]

feature_columns = {"timestamp": sessions}
for window in FEATURE_WINDOWS:
    computed = rolling_fft_features(returns, window, TARGET_PERIODS)
    for name in ["spectral_entropy", "low_freq_ratio", "dominant_period"]:
        feature_columns[f"{name}_{window}d"] = computed[name]

# Polars keeps NaN and null distinct and `drop_nulls` drops only the second, so the
# warm-up rows are converted before being dropped.
feature_df = (
    pl.DataFrame(feature_columns).with_columns(pl.exclude("timestamp").fill_nan(None)).drop_nulls()
)
print(f"Feature table: {feature_df.height:,} rows, {feature_df.width - 1} feature columns")
display(feature_df.tail(5))
```

## What they are worth

The **information coefficient** here is the Pearson correlation between a feature and the
return over the following five sessions. Two cautions before reading it. The forward
windows overlap, so consecutive rows are not independent and no significance follows from
the sample size. And a correlation computed once over a decade says nothing about whether
the relation held throughout it.

```python
FORWARD_SESSIONS = 5

forward = np.full(len(prices), np.nan)
forward[:-FORWARD_SESSIONS] = (prices[FORWARD_SESSIONS:] - prices[:-FORWARD_SESSIONS]) / prices[
    :-FORWARD_SESSIONS
]

scored = feature_df.join(
    pl.DataFrame({"timestamp": sessions, "forward_return": forward}).with_columns(
        pl.col("forward_return").fill_nan(None)
    ),
    on="timestamp",
    how="inner",
).drop_nulls()
forward_values = scored["forward_return"].to_numpy()

ic_df = pd.DataFrame(
    [
        {
            "feature": column,
            "information coefficient": np.corrcoef(scored[column].to_numpy(), forward_values)[0, 1],
            "sessions": scored.height,
        }
        for column in scored.columns
        if column not in ("timestamp", "forward_return")
    ]
).sort_values("information coefficient", key=abs, ascending=False)
display(ic_df)
```

## The features this notebook produces

| Column | What it measures | Causal |
|---|---|---|
| `spectral_entropy` | how evenly power is spread across frequencies, scaled to its maximum | yes |
| `dominant_period` | the period of the strongest oscillation in the window | yes |
| `low_freq_ratio` | the share of power in the two slowest oscillations the window resolves | yes |
| `energy_period_*` | the raw power in the bins around a named calendar period | yes |
| wavelet components | the series split by time scale | **no**, the transform reads the whole series |

`spectral_energy` is deliberately absent. It is the window variance rescaled, and a
rolling variance is the direct way to compute it.

## The library's spectral helpers

`ml4t.engineer` supplies two related expressions, and they answer different questions
from the ones above.

`rolling_entropy` bins the values in a window and computes the Shannon entropy of the
resulting histogram, in **bits**. It is an entropy of the distribution of returns, not of
their spectrum: the spectral entropy above asks how power is spread across frequencies,
this asks how the values are spread across a range. Its maximum is the base-two logarithm
of the bin count, which is what to read the printed mean against.

`fourier_features` is not a measurement of the series at all, and it is worth being clear
about this because the name suggests otherwise. It returns sine and cosine pairs of the
**row index**, at multiples of a base period the caller supplies. Nothing about the prices
enters them. They are a way of letting a linear model represent a smooth cycle of known
length, which is a legitimate and different job; the default base period is 390, the
number of trading minutes in a US session, so on daily data it must be given the period
that is meant.

```python
ENTROPY_WINDOW = 50
ENTROPY_BINS = 10
SEASONAL_PERIOD = 252  # sessions in a year, the cycle the sine and cosine pairs describe
SEASONAL_COMPONENTS = 3

seasonal = fourier_features("close", n_components=SEASONAL_COMPONENTS, period=SEASONAL_PERIOD)
engineered = spy.with_columns(**seasonal).with_columns(
    return_entropy=rolling_entropy("returns", window=ENTROPY_WINDOW, n_bins=ENTROPY_BINS)
)

entropy_values = engineered["return_entropy"].drop_nulls()
print(f"Rolling entropy of returns: mean {entropy_values.mean():.3f} bits")
print(f"Maximum possible with {ENTROPY_BINS} bins: {np.log2(ENTROPY_BINS):.3f} bits")
print(f"Seasonal basis columns: {len(seasonal)} for {SEASONAL_COMPONENTS} harmonics")
display(
    engineered.select(["timestamp", "fourier_sin_1", "fourier_cos_1", "return_entropy"]).tail(3)
)
```

The entropy sits close to its maximum, which is the distributional counterpart of the
flat spectrum: the returns in a fifty-session window spread across the bins about as
evenly as the binning allows. A window in which they concentrate would score lower, and
that is the departure the feature exists to catch.

## Key takeaways

1. **A wavelet decomposition is a research instrument.** It reads the whole series, so no
   component of it may be used as a feature. Its output is a decision about which time
   scales are worth computing rolling features at.
2. **The family matters less than the number of levels.** Three families give nearly the
   same slow component and differ only in the fastest detail, while the level count
   decides what each scale covers.
3. **Check what a derived feature reduces to.** Total spectral energy is the window
   variance times a constant, which a correlation of one and a fixed ratio establish, and
   a model given both is given one column twice.
4. **Scale a quantity that depends on the window length before comparing windows.**
   Spectral entropy grows with the number of frequencies in the window, so it is divided
   by its maximum here; the raw version ranks long windows above short ones for a reason
   that has nothing to do with the data.
5. **A flat spectrum is a finding.** Daily index returns have almost no periodic
   structure, so the spectral columns are informative about departures from that state
   rather than about a cycle. What does move is the level of the whole spectrum, which is
   volatility, and `08_garch_volatility` models it directly.

**Known limitations.** Every spectral quantity here assumes the window it reads comes
from one unchanging process, and a window containing a volatility break does not; the
estimate it returns is an average over two regimes. The information coefficients are
computed on overlapping forward windows over a single symbol and carry no significance.
And the wavelet scale-to-window mapping is a rule of thumb, checked here by correlation
rather than derived.

**Next**: `06_path_signatures` for features of the path rather than of its frequencies,
and `07_arima_features` for a model fitted in the time domain.
![notebook output](figures/p1_1.png)
![notebook output](figures/p1_2.png)
![notebook output](figures/p1_3.png)
![notebook output](figures/p1_4.png)
![notebook output](figures/p1_5.png)
![notebook output](figures/p1_6.png)
![notebook output](figures/p1_7.png)

出典を明記したうえで、ライセンスに従って全文を掲載しています。 ライセンス: MIT

この要約は原文をもとにStratmillのリサーチエージェントが作成したもので、出典の複製ではありません。