Signal transforms & processing
Radix-2 FFT and Haar wavelet transforms, two independently-implemented frequency-domain feature modules, window/peak/zero-crossing signal utilities, whole-column statistical data transforms, and the f
Six modules grouped under one name that turn out to span three unrelated problem domains, and two of the six are not what their name suggests. fft and wavelet are genuine frequency- and scale-domain transforms — a radix-2 Cooley-Tukey FFT and a Haar discrete wavelet transform. spectral and signal_processing both compute frequency-domain descriptive statistics (spectral centroid, flatness, energy) but are two separately-written modules that happen to share three function names with different signatures and different edge-case behavior, not one module split in two. transform, despite the name, is not a signal transform at all — there is no Laplace or Z-transform anywhere in this corpus; it is a whole-column statistical preprocessing library (Box-Cox, standardize, robust/min-max scaling, quantile and rank transforms) in the mold of sklearn.preprocessing, and two of its scaling functions turn out to be bit-identical to functions already documented in Classical ML algorithms. And series, which the name suggests might be low-level array plumbing, is in fact the frozen implementation of the classical baseline forecasters — naive, drift, seasonal-naive, SMA, single exponential smoothing — that forecasting.point, forecasting.fitted, forecasting.select and forecasting.contract import and call unchanged, verified directly against those modules' own from series import ... lines below.
from algenta import MojoRuntime
runtime = MojoRuntime(mode="local")
x = [1.0, 0.0, -1.0, 0.0] # one full cycle sampled at 4 points
X = runtime.execute("fft", "fft", [x])["result"] # 4 Complex bins (re, im)
mag = runtime.execute("fft", "fft_magnitude", [X])["result"]
mag
# [0.0, 2.0, 0.0, 2.0] -- all the signal's energy sits in bin 1 and its mirror, bin 3
ps = runtime.execute("fft", "power_spectrum", [x])["result"]
ps
# [0.0, 1.0, 0.0, 1.0] -- |X_k|^2 / N, the same shape without a separate magnitude stepThe modules
fft
Radix-2 Cooley-Tukey FFT/IFFT over a custom Complex struct, magnitude, phase, power spectrum, and the power-of-2 sizing helpers (next_power_of_2, zero_pad) meant to run before it.
spectral
Frequency-domain analysis with its own internal DFT: power/cross spectral density, coherence, spectral centroid/bandwidth/rolloff/flatness/entropy, band energy, and peak frequency — all take an explicit, caller-supplied frequency axis.
wavelet
Haar discrete wavelet transform only (forward/inverse, single- and multi-level), hard/soft thresholding, denoising, and detail/approximation-energy extraction.
transform
Whole-column statistical data transforms — log, log1p, Box-Cox, Z-score standardize, min-max scale, robust (median/IQR) scale, quantile rank, power, clamp, rank, abs, reciprocal — not a signal or integral transform.
signal_processing
Window functions (Hamming/Hann/Blackman), windowing, peak detection, zero-crossing counting, and its own smaller set of spectral features (energy, centroid, flatness) keyed off a sample rate rather than an explicit frequency list.
series
Classical baseline forecasters — SMA, OLS linear extrapolation, weighted moving average, single exponential smoothing, naive, seasonal-naive, drift — plus forecast-accuracy (MAE/MAPE/RMSE), trend strength, and autocorrelation-based seasonality detection.
fft: correct on a power of 2, silently wrong off it
fft(x)
Cooley-Tukey radix-2 decimation-in-time FFT. Recursively splits even/odd indices; each level assumes the length halves evenly.
ifft(X)
Same recursion with the twiddle-angle sign flipped, then divided by N.
fft_magnitude(X)
sqrt(re^2 + im^2) per bin.
fft_phase(X)
atan2(im, re) per bin, via a minimax polynomial atan (no std.math.atan2 call).
power_spectrum(x)
fft(x) then |X_k|^2 / N in one call.
next_power_of_2(n)
Smallest power of 2 >= n.
zero_pad(x, target_len)
Appends zeros up to target_len; does not truncate if x is already longer.
Both fft(0-length) and power_spectrum(0-length) are explicitly guarded — the source's own comment explains why: an empty list would split into two empty halves forever, "a stack-overflow abort" rather than a clean answer, so length 0 returns an empty list instead of recursing.
There is no guard for a non-power-of-2, non-zero length, and the recursion does not fail loudly when it hits one — it silently drops input. Feeding the 5-element fixture the auto-generated test uses ([0.5, 0.6, 0.7, 0.4, 0.3]) straight into fft gives [2.5, 0.1618-0.1902i, 0.5, 0.0382+0.1902i, 0] — bin 0 (the plain sum) happens to be correct, but every other bin is wrong, and the top bin isn't merely inaccurate, it is exactly 0, because the n=3 sub-call only has room to combine one pair from a 2-element even half and a 1-element odd half, and the second even-half element is never read. Re-running the identical recursion independently in Python and cross-checking numpy.fft.fft on the same 5-point input confirms the divergence (numpy gives [2.5, -0.112-0.462i, 0.112+0.109i, 0.112-0.109i, -0.112+0.462i]). The documented fix is the two helper functions sitting right next to fft in the public API:
fft and ifft are exact inverses on a power-of-2 input, up to floating-point rounding — ifft(fft([1.0, 0.0, -1.0, 0.0])) returns [1.0, 0.0, -1.0, 0.0] (as complex numbers with near-zero imaginary parts), verified independently in Python against the same recursion.
spectral and signal_processing: three shared names, two different modules
Both modules define a function called spectral_centroid and one called spectral_flatness (signal_processing additionally has spectral_energy; spectral additionally has power_spectral_density, cross_spectral_density, coherence, spectral_bandwidth, spectral_rolloff, spectral_entropy, band_energy, and peak_frequency). These are not two call-paths into shared code — reading both .mojo files directly shows two independent implementations with different signatures:
Function
signal_processing signature
spectral signature
spectral_centroid
(magnitudes, sample_rate) — derives its own bin frequencies as k * sample_rate / (2N)
(magnitudes, frequencies) — trusts whatever frequency axis the caller hands it
spectral_flatness
(magnitudes) — floors any magnitude below 1e-20 up to 1e-20 rather than refusing
(magnitudes) — returns exactly 0.0 the instant any single magnitude is <= 1e-30
Calling the wrong module's version compiles and returns a plausible float either way — nothing checks that a sample_rate-shaped call landed in spectral or a frequencies-shaped call landed in signal_processing — it is simply the wrong number for the arguments given, silently.
signal_processing's spectral trio, worked on the 4-bin magnitude spectrum from the FFT example above ([0.0, 2.0, 0.0, 2.0], sample rate 8.0 Hz):
spectral's own centroid/bandwidth/rolloff/band-energy/peak-frequency family, over 3 bins with an explicit frequency axis [0.0, 1.0, 2.0] Hz and magnitudes [0.0, 2.0, 0.0]:
And spectral's PSD/CSD/coherence trio, on the same 4-point x = [1, 0, -1, 0] against a 90-degree-shifted y = [0, 1, 0, -1] at fs = 4.0:
spectral's internal DFT is a direct O(N^2) sum, not a call into fft — the two modules do not share code even where they could.
wavelet: Haar only, despite the plural in its own header
The module docstring's header line reads "Wavelet transforms," but the body implements exactly one wavelet family — Haar — with no Daubechies, Morlet, or any other basis anywhere in the file or the runtime contract. Every function name in the public API says haar_* except the generic-sounding threshold_hard / threshold_soft / denoise_haar / energy_ratio / detail_coefficients / approx_coefficients, which are all Haar-coefficient-shaped operations, not wavelet-family-agnostic ones.
haar_forward(data)
1-level Haar DWT: [averages..., details...], each (x[2k] +/- x[2k+1]) / sqrt(2).
haar_inverse(coeffs)
Exact inverse of haar_forward.
haar_multilevel(data, levels)
Recursively re-transforms the approximation half; output layout is [approx_L, detail_L, detail_{L-1}, ..., detail_1].
haar_multilevel_inverse(coeffs, levels)
Exact inverse of haar_multilevel.
threshold_hard(coeffs, threshold)
Zero out any coefficient with |c| < threshold.
threshold_soft(coeffs, threshold)
sign(c) * max(|c| - threshold, 0).
denoise_haar(data, threshold, levels=1)
haar_multilevel -> threshold_soft -> haar_multilevel_inverse, composed.
energy_ratio(coeffs)
Detail-energy / total-energy, assuming coeffs is a 1-level [approx, detail] split.
detail_coefficients(coeffs)
Second half of a 1-level split.
approx_coefficients(coeffs)
First half of a 1-level split.
Round-tripping [1.0, 2.0, 3.0, 4.0] through haar_forward then haar_inverse recovers the input to within floating-point rounding (verified independently in Python: haar_forward gives [2.1213203435596424, 4.949747468305833, -0.7071067811865475, -0.7071067811865475], and haar_inverse of that gives back [0.9999999999999999, 1.9999999999999998, 3.0, 3.9999999999999996]). Two levels on [1..8] behave the same way: haar_multilevel([1..8], 2) gives [5.0, 13.0, -2.0, -2.0, -0.7071..., -0.7071..., -0.7071..., -0.7071...], and haar_multilevel_inverse of that at levels=2 recovers [1..8] to the same precision. denoise_haar([1..8], 1.0, levels=2) — soft-thresholding those same coefficients at 1.0 before inverting — collapses the reconstruction to [1.5, 1.5, 2.5, 2.5, 5.5, 5.5, 6.5, 6.5], four flat pairs, because every detail coefficient in this particular input has magnitude <= 1.0 and gets zeroed, leaving only the coarsest approximation to reconstruct from.
energy_ratio's docstring names its own precondition, and violating it is silent. It splits coeffs at n // 2 and calls the back half "detail energy" — correct for haar_forward's 1-level output, but haar_multilevel's 2-level output above is laid out as [approx_2 (2), detail_2 (2), detail_1 (4)]; feeding that 8-element array to energy_ratio puts detail_2 on the "approximation" side of its n // 2 split rather than the detail side, silently mixing one level of detail energy into what the function reports as the total-minus-detail remainder. energy_ratio is only correct on a genuine 1-level haar_forward output.
transform: statistical preprocessing, not a mathematical transform
This is the one module on this page whose real subject is furthest from what its name suggests. A name like transform in a signal-processing context reads as Laplace, Z-transform, or similar — this corpus has none of those anywhere. What transform.mojo actually implements, per its own docstring, is "sklearn.preprocessing" reimplemented in pure Mojo: whole-List[Float64]-column statistical transforms that compute their own summary statistics internally.
log_transform(data)
ln(x); raises on any x <= 0.
log1p_transform(data)
ln(1+x); raises if 1+x <= 0.
box_cox(data, lmbda)
(x^lmbda - 1)/lmbda for lmbda != 0, ln(x) at lmbda == 0; raises on any x <= 0.
standardize(data)
(x - mean) / population_std; raises on empty input or zero std.
min_max_scale(data, new_min=0, new_max=1)
Scales to [new_min, new_max]; raises on empty or constant data.
robust_scale(data)
(x - median) / IQR (linear-interpolated quartiles); raises on empty data or zero IQR.
quantile_transform(data)
Percentile rank in [0, 1], ties averaged; raises on empty input.
power_transform(data, power)
x^power via exp(power * ln(|x|)), sign-preserved for negative x; 0.0 stays 0.0.
clamp_values(data, lo, hi)
Clamp each value to [lo, hi].
rank_transform(data)
1-based rank, ties averaged.
abs_transform(data)
|x| elementwise.
reciprocal_transform(data)
1/x; raises if |x| < 1e-15.
Eight of the twelve functions are declared raises in Mojo and genuinely propagate an Error on degenerate input, rather than returning 0.0 or an empty list the way most of the other modules on this page do — a caller treating every function here as "returns a value or a sentinel" will be surprised the first time one of these throws.
Two of transform's scaling functions are bit-identical to ml.preprocessing's, and the third diverges on purpose. Classical ML algorithms documents ml.preprocessing.normalize_minmax / normalize_zscore / normalize_robust on the canonical eight-point dataset [2, 4, 4, 4, 5, 5, 7, 9]. Running the same input through transform:
min_max_scale and robust_scale use the same lo/hi and median/IQR formulas as their ml.preprocessing counterparts and land on the exact same numbers. standardize diverges because it divides by the population standard deviation (sqrt(sum((x-mean)^2) / n)), while ml.preprocessing.normalize_zscore — documented in the same page — uses math_ext.stdev, the sample standard deviation (divides by n - 1). Porting code that assumes one or the other's exact output needs to account for that difference, especially at small n where the two diverge most.
This makes transform a third sibling in the scaling-formula family Feedback loops and feature engineering already documents two of: ml.preprocessing takes a whole column and computes its own statistics (like transform), while feature_engineering takes one value plus caller-supplied statistics and returns one scaled value. transform sits with ml.preprocessing on the "whole column, self sufficient" side of that split, not with feature_engineering.
box_cox at lmbda = 0.0 reduces to log_transform exactly, by construction (fabs(lmbda) < 1e-12 branches to ln(x) directly rather than computing (x^0 - 1)/0). power_transform is the one function in the table with no raises clause and no zero-guard beyond x == 0.0 -> 0.0: negative inputs are handled by computing exp(power * ln(|x|)) and re-applying the original sign, so power_transform([-8.0], 1.0/3.0) returns -1.9999999999999998, a real cube root up to floating-point rounding, rather than raising on the negative base the way a naive x**power would in a language that refuses fractional powers of negative numbers.
series: the frozen substrate under forecasting.point
This module's name suggests generic time-series array plumbing sitting below the ARIMA and forecasting model pages. Reading series.mojo confirms the classical-forecaster half of that guess, and grepping the real forecasting/*.mojo source confirms the "underneath" part directly: forecasting/point.mojo, forecasting/fitted.mojo, forecasting/select.mojo, and forecasting/contract.mojo each contain a literal from series import ... line, and forecasting.point's own module docstring states the relationship in a table:
SES level path / level
series.exponential_forecast
naive / drift / window mean
series.naive_forecast, drift_forecast, sma_forecast
seasonal naive
series.seasonal_naive
OLS intercept and slope
series._ols_fit
— i.e. the "four baselines" that Forecasting and backtesting describes forecasting.point as offering are series's own naive_forecast, drift_forecast, sma_forecast, and seasonal_naive, called unchanged; forecasting.point adds validation, an absence marker, and a status code on top, but does not re-implement the arithmetic. series is distinct from the two other time-series-adjacent modules already documented: it does not fit AR/MA coefficients (that is arima, in AR/MA estimation) and it does not compute ACF/PACF diagnostics (that is timeseries_stats, also on that page) — series is baseline forecasters and their scoring, nothing else.
sma_forecast(data, window, horizon)
Flat forecast at the mean of the last window points (clamped to len(data)).
linear_extrapolation(data, horizon)
OLS line fit to the whole series, extrapolated.
weighted_moving_avg(data, weights)
Weighted average of the last len(weights) points, one scalar.
exponential_forecast(data, alpha, horizon)
Single exponential smoothing; every horizon step repeats the final smoothed level.
naive_forecast(data, horizon)
Repeats the last observed value.
seasonal_naive(data, period, horizon)
Repeats the last full season, cyclically.
drift_forecast(data, horizon)
Straight line through the first and last points, extrapolated.
forecast_accuracy(actual, predicted)
Returns [MAE, MAPE, RMSE] as a 3-element list.
trend_strength(data)
1 - var(OLS residuals) / var(data), floored at 0.0.
detect_seasonality(data, max_period)
Dominant period in 2..max_period by highest autocorrelation.
Worked on a 12-point series with both a trend and a period-4 wobble, y = [10, 12, 9, 14, 11, 13, 10, 15, 12, 14, 11, 16]:
forecast_accuracy([12.0, 14.0, 11.0], [11.5, 14.5, 10.0]) returns [0.6666666666666666, 5.609668109668109, 0.7071067811865476] — MAE, MAPE (as a percentage), then RMSE, in that fixed order; there is no flag distinguishing "MAPE undefined at an exact-zero actual" the way forecasting.backtest.forecast_errors_flat reports it — series.forecast_accuracy simply skips zero-actual points inside its own MAPE sum without recording how many it skipped.
naive_forecast, drift_forecast, and seasonal_naive all read data at negative or out-of-bounds-adjacent indices for pathological inputs (empty data, period <= 0, period > n) that would be uncatchable aborts in Mojo rather than a Python-style exception; every one of these guards the pathological case explicitly and returns horizon zeros (or, for drift_forecast with fewer than 2 points, data[0] repeated) instead — the source code's own comments name exactly which read would otherwise go out of bounds.
Verification
All six mojo/tests/libs/test_*.mojo files behind this page (test_fft.mojo, test_spectral.mojo, test_wavelet.mojo, test_transform.mojo, test_signal_processing.mojo, test_series.mojo) are auto-generated coverage smoke tests: each calls every function once against a fixed [0.5, 0.6, 0.7, 0.4, 0.3]-style fixture and checks only that the result is finite or non-negative length — none pin a specific numeric output. Notably, test_fft.mojo calls fft directly on that 5-element (non-power-of-2) fixture and only asserts len(result) >= 0, which is true regardless of correctness — the smoke suite does not exercise, and cannot catch, the silent-truncation behavior described above.
Every worked number on this page was independently computed by re-implementing the real formulas from mojo/engine/fft.mojo, spectral.mojo, wavelet.mojo, transform.mojo, signal_processing.mojo, and series.mojo in Python and running the arithmetic directly — the power-of-2 FFT results were additionally cross-checked against numpy.fft.fft on the same inputs, matching to full float precision. Every number can be reproduced by re-running the same arithmetic.
Next: Forecasting and backtesting and AR/MA estimation for the higher-level models series sits underneath, Classical ML algorithms for the ml.preprocessing scaling functions transform overlaps with, or the Runtime library catalog for every module and function.
Last updated
Was this helpful?