Artificial IntelligenceOctober 28, 2026

Fast Fourier Neural Operator on Random-Binned Time Series

N

Written by

Nova Neural

The problem that pulled me in

I wanted to predict a signal from noisy time-series measurements where the sampling times aren’t uniform. Most deep learning examples assume you get a nice, evenly spaced grid—turns out that’s rarely true in the real world (think: clocks drift, sensors miss samples, events happen irregularly).

I tried a couple “usual suspects” and they all fought the data shape. Then I stumbled on a pattern that felt oddly specific but practical: use a Neural Operator (a model that learns mappings between functions, not just vectors) and combine it with an FFT-style trick to make the time-frequency representation stable.

The niche part: I used random-binned time warping before the model ever saw the sequence. The idea is simple: map irregular timestamps onto a fixed-size grid using random bin edges so the model doesn’t overfit to any one binning scheme.

This post shows a small, runnable implementation of a Fourier-style Neural Operator for regression on irregularly sampled 1D signals.


What I built (high level)

  1. Generate synthetic 1D signals (combinations of sinusoids) and sample them at irregular timestamps.
  2. Convert each irregular sample set into a fixed-length “binned time series” using random binning.
  3. Train a tiny Fourier Neural Operator (FNO)-like network:
    • lift input to a channel dimension
    • apply several spectral convolution blocks using FFT
    • project back to the target signal

A working end-to-end example (PyTorch)

Install / imports

import math import random from dataclasses import dataclass import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import DataLoader, Dataset

Step 1: Create irregular time-series data

I’m generating a clean target function y(t) and then sampling it at irregular times t_i with noise.

  • The target is a sum of sinusoids with random frequencies and phases.
  • The input is those noisy observations plus a “mask-ish” indicator (how many points landed in a bin).
def sample_signal_on_times(t, num_terms=3): # t: (...,) y = torch.zeros_like(t) for _ in range(num_terms): freq = torch.rand(1).item() * 8.0 + 1.0 # 1..9 Hz-like phase = torch.rand(1).item() * 2 * math.pi amp = torch.rand(1).item() * 1.0 + 0.2 y = y + amp * torch.sin(2 * math.pi * freq * t + phase) return y class IrregularSineDataset(Dataset): def __init__(self, n_samples=2000, n_obs_range=(10, 40), noise_std=0.05, seed=0): super().__init__() self.n_samples = n_samples self.n_obs_range = n_obs_range self.noise_std = noise_std self.rng = np.random.default_rng(seed) # Fixed output grid for the target self.t0, self.t1 = 0.0, 1.0 def __len__(self): return self.n_samples def __getitem__(self, idx): # Irregular observation times (within [0, 1]) n_obs = self.rng.integers(self.n_obs_range[0], self.n_obs_range[1] + 1) t_obs = np.sort(self.rng.uniform(self.t0, self.t1, size=(n_obs,)).astype(np.float32)) t_obs_t = torch.tensor(t_obs) y_clean = sample_signal_on_times(t_obs_t, num_terms=3) y_noisy = y_clean + torch.randn_like(y_clean) * self.noise_std # We'll also return a target sampled on a uniform grid n_target = 128 t_grid = torch.linspace(self.t0, self.t1, n_target) y_target = sample_signal_on_times(t_grid, num_terms=3) return t_obs_t, y_noisy, t_grid, y_target

Step 2: Random-binned time warping (the niche trick)

Each item includes irregular samples (t_obs, y_noisy). We need a consistent fixed-length input vector for the model.

I use random bin edges each time we transform the sample:

  • create B bins across [0, 1]
  • assign each observation to a bin
  • fill a binned signal with:
    • average of y_noisy in the bin (or 0 if empty)
    • a binary/count feature that tells the model whether a bin had any observations

This is effectively a learned-friendly regularization for sampling artifacts.

@dataclass class RandomBinnerConfig: B: int = 128 jitter_bins: bool = True max_jitter: float = 0.02 # jitter magnitude in normalized time units class RandomBinner: def __init__(self, cfg: RandomBinnerConfig, seed=0): self.cfg = cfg self.rng = np.random.default_rng(seed) def transform(self, t_obs, y_obs): """ t_obs: (n_obs,) y_obs: (n_obs,) Returns: x: (2, B) with channels: x[0] = binned average y x[1] = occupancy count normalized """ B = self.cfg.B # Create bin edges. If jitter_bins=True, perturb internal edges. edges = np.linspace(0.0, 1.0, B + 1).astype(np.float32) if self.cfg.jitter_bins: # jitter internal edges only internal = edges[1:-1] jitter = self.rng.uniform(-self.cfg.max_jitter, self.cfg.max_jitter, size=internal.shape).astype(np.float32) internal = np.clip(internal + jitter, 0.0, 1.0) # sort to keep edges monotonic internal = np.sort(internal) edges[1:-1] = internal edges = np.clip(edges, 0.0, 1.0) # Assign each time to a bin index in [0, B-1] t_np = t_obs.detach().cpu().numpy() bin_idx = np.searchsorted(edges, t_np, side="right") - 1 bin_idx = np.clip(bin_idx, 0, B - 1) y_np = y_obs.detach().cpu().numpy() sums = np.zeros(B, dtype=np.float32) counts = np.zeros(B, dtype=np.float32) for i in range(len(bin_idx)): b = bin_idx[i] sums[b] += y_np[i] counts[b] += 1.0 # Average; bins with zero counts stay 0 avg = np.zeros(B, dtype=np.float32) nonzero = counts > 0 avg[nonzero] = sums[nonzero] / counts[nonzero] # occupancy normalized to [0,1]-ish by count cap (roughly) occupancy = np.clip(counts / max(1.0, counts.max()), 0.0, 1.0) x = np.stack([avg, occupancy], axis=0) # (2, B) return torch.tensor(x, dtype=torch.float32)

Step 3: Fourier spectral convolution block (FNO-ish)

A spectral convolution takes the FFT of the signal, applies learned complex weights to a subset of frequencies, and transforms back.

Key detail: PyTorch FFT outputs complex tensors, so weights are represented as two real parameters (real + imaginary).

class SpectralConv1d(nn.Module): def __init__(self, in_ch, out_ch, modes): super().__init__() self.in_ch = in_ch self.out_ch = out_ch self.modes = modes # number of low-frequency modes to learn # Complex weights represented as real/imag parts # Shape: (in_ch, out_ch, modes) scale = 1.0 / (in_ch * out_ch) self.weight_real = nn.Parameter(scale * torch.randn(in_ch, out_ch, modes)) self.weight_imag = nn.Parameter(scale * torch.randn(in_ch, out_ch, modes)) def forward(self, x): """ x: (batch, in_ch, B) returns: (batch, out_ch, B) """ B = x.shape[-1] # FFT along the last dimension x_ft = torch.fft.rfft(x, dim=-1) # (batch, in_ch, B//2+1) complex # Prepare output spectrum out_ft = torch.zeros( x.shape[0], self.out_ch, x_ft.shape[-1], dtype=torch.complex64, device=x.device ) # Apply learned weights to the first `modes` frequency bins m = min(self.modes, x_ft.shape[-1]) x_ft_low = x_ft[:, :, :m] # (batch, in_ch, m) # Compute complex multiplication: # out_ft[..., k] = sum_in x_ft[..., in, k] * W[in, out, k] w = torch.complex(self.weight_real[..., :m], self.weight_imag[..., :m]) # (in_ch, out_ch, m) out_ft[:, :, :m] = torch.einsum("bim,iom->bom", x_ft_low, w) # Inverse FFT back to time domain y = torch.fft.irfft(out_ft, n=B, dim=-1) # (batch, out_ch, B) return y

Step 4: A tiny Neural Operator model

I’ll implement:

  • input projection: 2 -> width
  • 4 spectral blocks with GELU and a skip connection
  • output projection: width -> 1 (predict target signal on B bins)

Because our target is on a fixed uniform grid of length 128, we’ll set B=128 and train directly.

class FNO1dTiny(nn.Module): def __init__(self, modes=32, width=32, in_ch=2, out_ch=1, n_layers=4): super().__init__() self.in_proj = nn.Conv1d(in_ch, width, kernel_size=1) self.spectral_layers = nn.ModuleList([ SpectralConv1d(width, width, modes) for _ in range(n_layers) ]) self.pointwise = nn.ModuleList([ nn.Conv1d(width, width, kernel_size=1) for _ in range(n_layers) ]) self.out_proj1 = nn.Conv1d(width, width, kernel_size=1) self.out_proj2 = nn.Conv1d(width, out_ch, kernel_size=1) def forward(self, x): """ x: (batch, 2, B) returns: (batch, 1, B) """ x = self.in_proj(x) for spec, pw in zip(self.spectral_layers, self.pointwise): y = spec(x) + pw(x) x = F.gelu(y) x = F.gelu(self.out_proj1(x)) x = self.out_proj2(x) return x

Step 5: Training loop

I’ll:

  • bin irregular inputs with random binning each epoch
  • train with MSE between predicted and target on the uniform grid
device = "cuda" if torch.cuda.is_available() else "cpu" # Config B = 128 cfg = RandomBinnerConfig(B=B, jitter_bins=True, max_jitter=0.02) binner = RandomBinner(cfg, seed=42) dataset = IrregularSineDataset(n_samples=2500, n_obs_range=(12, 45), noise_std=0.07, seed=123) val_dataset = IrregularSineDataset(n_samples=400, n_obs_range=(12, 45), noise_std=0.07, seed=456) train_loader = DataLoader(dataset, batch_size=32, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False) model = FNO1dTiny(modes=32, width=32, in_ch=2, out_ch=1, n_layers=4).to(device) opt = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=1e-4) def to_batch(batch): # batch is a list of tuples returned by dataset xs = [] ys = [] for (t_obs, y_obs, t_grid, y_target) in batch: x = binner.transform(t_obs, y_obs) # (2,B) xs.append(x) # y_target is (B,) ys.append(y_target) X = torch.stack(xs, dim=0) # (batch,2,B) Y = torch.stack(ys, dim=0).unsqueeze(1) # (batch,1,B) return X.to(device), Y.to(device) @torch.no_grad() def evaluate(loader): model.eval() total_loss = 0.0 n = 0 for batch in loader: X, Y = to_batch(batch) pred = model(X) loss = F.mse_loss(pred, Y) bs = X.shape[0] total_loss += loss.item() * bs n += bs return total_loss / n epochs = 15 for epoch in range(1, epochs + 1): model.train() running = 0.0 n = 0 for batch in train_loader: X, Y = to_batch(batch) pred = model(X) loss = F.mse_loss(pred, Y) opt.zero_grad() loss.backward() opt.step() bs = X.shape[0] running += loss.item() * bs n += bs train_loss = running / n val_loss = evaluate(val_loader) print(f"Epoch {epoch:02d} | train MSE {train_loss:.5f} | val MSE {val_loss:.5f}")

Step 6: Visual sanity check

Here’s a quick plot for one example: compare noisy irregular observations (as points), the binned input signal, the model prediction, and the clean target grid.

import matplotlib.pyplot as plt # Grab one sample t_obs, y_obs, t_grid, y_target = val_dataset[0] x_binned = binner.transform(t_obs, y_obs) # (2,B) model.eval() with torch.no_grad(): X = x_binned.unsqueeze(0).to(device) # (1,2,B) pred = model(X).squeeze(0).squeeze(0).cpu() # (B,) plt.figure(figsize=(10,4)) plt.plot(t_grid.numpy(), y_target.numpy(), label="target (clean)", linewidth=2) plt.plot(t_grid.numpy(), pred.numpy(), label="prediction", linewidth=2, linestyle="--") # Show irregular observations as scatter plt.scatter(t_obs.numpy(), y_obs.numpy(), s=15, alpha=0.7, label="noisy observations") plt.title("Fourier Neural Operator with Random-Binned Inputs") plt.xlabel("t") plt.ylabel("signal") plt.legend() plt.tight_layout() plt.show()

What I learned (the practical takeaways)

The biggest surprise was that the random-binned time warping wasn’t just preprocessing—it acted like a regularizer. Without it, the network latched onto specific bin boundaries and struggled when the irregular samples shifted slightly. With jittered edges, the spectral convolution learned a mapping that was more stable under resampling artifacts.

On the model side, the FFT-based spectral convolution made the network efficient and “frequency-aware” even for noisy data. The low-frequency modes often carried most of the regression signal, while higher frequencies were less reliable given the irregular sampling.


Conclusion

I built a small Fourier-style Neural Operator that can predict a clean function on a fixed grid from irregular, noisy time-series samples by first converting them into inputs using random-binned time warping. The working implementation shows how irregular sampling can be handled without complicated interpolation: bin with jitter for robustness, then let an FFT-based spectral model learn the function mapping in frequency space.