Source code for routee.powertrain.trainers.cnn

from __future__ import annotations

import logging
import time
from typing import Literal

import numpy as np
import onnxruntime as rt
import pandas as pd

from routee.powertrain.core.model_config import ModelConfig
from routee.powertrain.estimators.estimator_interface import Estimator, InputSpec
from routee.powertrain.estimators.onnx import ONNX_INPUT_NAME, ONNXEstimator
from routee.powertrain.trainers.trainer import Trainer
from routee.powertrain.utils.threading import get_restricted_threads

log = logging.getLogger(__name__)

DEFAULT_LOOKBACK = 5
DEFAULT_GROUPING_COLUMN = "route_id"


def _auto_n_conv_layers(lookback: int, kernel_size: int) -> int:
    """Pick the most VALID-padded conv layers that fit a given lookback.

    Each kernel-K VALID conv shrinks the sequence by K-1. We stack until the
    next layer would not have enough input. This makes the receptive field at
    the head match (or as close as possible to) the lookback window.
    """
    n = lookback
    layers = 0
    while n - kernel_size + 1 >= 1:
        n = n - kernel_size + 1
        layers += 1
    return max(1, layers)


[docs] class CNNTrainer(Trainer): """Train a 1D CNN over link sequences with a fixed lookback window. The training input dataframe must contain the grouping column (default ``route_id``) so training windows don't cross route boundaries. The trained model is exported to ONNX for runtime use, keeping ``torch`` out of the runtime dependency set. """ architecture_tag: str = "cnn" default_test_size: float = 0.1 default_validation_size: float = 0.1 @property def required_extra_columns(self): return [self.grouping_column] @property def split_grouping_column(self) -> str | None: return self.grouping_column def __init__( self, lookback: int = DEFAULT_LOOKBACK, grouping_column: str = DEFAULT_GROUPING_COLUMN, pad_strategy: Literal["zero", "repeat_first"] = "zero", hidden_channels: int = 64, kernel_size: int = 3, n_conv_layers: int | None = None, epochs: int = 50, batch_size: int = 4096, learning_rate: float = 1e-3, min_learning_rate: float = 1e-5, weight_decay: float = 1e-4, grad_clip_norm: float = 1.0, normalize_features: bool = True, random_seed: int = 52, device: str | None = None, early_stopping_patience: int | None = None, early_stopping_min_delta: float = 1e-6, warmup_epochs: int = 0, ): self.lookback = lookback self.grouping_column = grouping_column self.pad_strategy = pad_strategy self.hidden_channels = hidden_channels self.kernel_size = kernel_size self.n_conv_layers = ( n_conv_layers if n_conv_layers is not None else _auto_n_conv_layers(lookback, kernel_size) ) self.epochs = epochs self.batch_size = batch_size self.learning_rate = learning_rate self.min_learning_rate = min_learning_rate self.weight_decay = weight_decay self.grad_clip_norm = grad_clip_norm self.normalize_features = normalize_features self.random_seed = random_seed self.device = device self.early_stopping_patience = early_stopping_patience self.early_stopping_min_delta = early_stopping_min_delta self.warmup_epochs = warmup_epochs
[docs] def inner_train( self, features: pd.DataFrame, target: pd.DataFrame, config: ModelConfig, validation_features: pd.DataFrame | None = None, validation_target: pd.DataFrame | None = None, ) -> Estimator: try: import torch from torch import nn except ImportError as exc: raise ImportError( "CNNTrainer requires torch. Install via pip install routee.powertrain[pytorch]" ) from exc if self.grouping_column not in features.columns: raise ValueError( f"CNNTrainer requires a '{self.grouping_column}' column in the " f"training features so windows can be built per route." ) def _pick_device(override: str | None) -> "torch.device": if override is not None: return torch.device(override) if torch.cuda.is_available(): return torch.device("cuda") if torch.backends.mps.is_available(): return torch.device("mps") return torch.device("cpu") device = _pick_device(self.device) torch.manual_seed(self.random_seed) np.random.seed(self.random_seed) feature_cols = [c for c in features.columns if c != self.grouping_column] feature_matrix = features[feature_cols].to_numpy(dtype=np.float32) y_matrix = target.to_numpy(dtype=np.float32) if y_matrix.ndim == 1: y_matrix = y_matrix.reshape(-1, 1) n_features = len(feature_cols) n_targets = y_matrix.shape[1] if self.normalize_features: feat_mean = feature_matrix.mean(axis=0).astype(np.float32) feat_std = feature_matrix.std(axis=0).astype(np.float32) feat_std[feat_std < 1e-8] = 1.0 else: feat_mean = np.zeros(n_features, dtype=np.float32) feat_std = np.ones(n_features, dtype=np.float32) def _build_windows(df: pd.DataFrame, matrix: np.ndarray) -> np.ndarray: windows = np.zeros((len(df), self.lookback, n_features), dtype=np.float32) groups = df.groupby(self.grouping_column, sort=False).indices for idx_array in groups.values(): idx_array = np.asarray(idx_array) group_features = matrix[idx_array] if self.pad_strategy == "repeat_first": pad_row = group_features[:1] else: pad_row = np.zeros((1, n_features), dtype=np.float32) padded = np.concatenate( [np.repeat(pad_row, self.lookback - 1, axis=0), group_features], axis=0, ) for i, row_pos in enumerate(idx_array): windows[row_pos] = padded[i : i + self.lookback] return windows windows = _build_windows(features, feature_matrix) X_cpu = torch.from_numpy(windows) y_cpu = torch.from_numpy(y_matrix) X_val_cpu: torch.Tensor | None = None y_val_cpu: torch.Tensor | None = None if validation_features is not None and validation_target is not None: if self.grouping_column not in validation_features.columns: raise ValueError( f"CNNTrainer requires a '{self.grouping_column}' column in the " f"validation features so windows can be built per route." ) validation_matrix = validation_features[feature_cols].to_numpy( dtype=np.float32 ) y_val_matrix = validation_target.to_numpy(dtype=np.float32) if y_val_matrix.ndim == 1: y_val_matrix = y_val_matrix.reshape(-1, 1) X_val_cpu = torch.from_numpy( _build_windows(validation_features, validation_matrix) ) y_val_cpu = torch.from_numpy(y_val_matrix) n_train = X_cpu.shape[0] log.info("CNN training rows: %d", n_train) def _train_on(dev: "torch.device"): torch.manual_seed(self.random_seed) # Move the full training tensors to the target device once. X_dev = X_cpu.to(dev) y_dev = y_cpu.to(dev) X_val_dev = X_val_cpu.to(dev) if X_val_cpu is not None else None y_val_dev = y_val_cpu.to(dev) if y_val_cpu is not None else None m = _CNN1D( n_features=n_features, hidden_channels=self.hidden_channels, kernel_size=self.kernel_size, n_conv_layers=self.n_conv_layers, n_targets=n_targets, feat_mean=feat_mean, feat_std=feat_std, ) m = m.to(dev) optimizer = torch.optim.AdamW( m.parameters(), lr=self.learning_rate, weight_decay=self.weight_decay, ) steps_per_epoch = max(1, (n_train + self.batch_size - 1) // self.batch_size) warmup_steps = self.warmup_epochs * steps_per_epoch cosine_epochs = max(1, self.epochs - self.warmup_epochs) cosine_steps = max(1, cosine_epochs * steps_per_epoch) cosine_scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=cosine_steps, eta_min=self.min_learning_rate, ) if warmup_steps > 0: warmup_scheduler = torch.optim.lr_scheduler.LinearLR( optimizer, start_factor=1e-8, total_iters=warmup_steps, ) scheduler: torch.optim.lr_scheduler.LRScheduler = ( torch.optim.lr_scheduler.SequentialLR( optimizer, schedulers=[warmup_scheduler, cosine_scheduler], milestones=[warmup_steps], ) ) else: scheduler = cosine_scheduler loss_fn = nn.MSELoss() m.train() log.info( "CNN training: device=%s epochs=%d batches/epoch=%d hidden=%d conv_layers=%d", dev.type, self.epochs, steps_per_epoch, self.hidden_channels, self.n_conv_layers, ) rng = np.random.default_rng(self.random_seed) best_val_loss = float("inf") best_state: dict | None = None patience_counter = 0 stopped_early = False for epoch_idx in range(self.epochs): epoch_start = time.time() perm = torch.from_numpy(rng.permutation(n_train)).to(dev) loss_accum = torch.zeros((), device=dev) n_batches = 0 for s in range(0, n_train, self.batch_size): b = perm[s : s + self.batch_size] xb = X_dev[b] yb = y_dev[b] optimizer.zero_grad() pred = m(xb) loss = loss_fn(pred, yb) loss.backward() if self.grad_clip_norm is not None and self.grad_clip_norm > 0: torch.nn.utils.clip_grad_norm_( m.parameters(), max_norm=self.grad_clip_norm ) optimizer.step() scheduler.step() loss_accum += loss.detach() n_batches += 1 avg_loss = (loss_accum / max(1, n_batches)).item() val_loss = float("nan") if X_val_dev is not None and y_val_dev is not None: m.eval() with torch.no_grad(): val_loss = loss_fn(m(X_val_dev), y_val_dev).item() m.train() if self.early_stopping_patience is not None: if val_loss < best_val_loss - self.early_stopping_min_delta: best_val_loss = val_loss best_state = { k: v.clone() for k, v in m.state_dict().items() } patience_counter = 0 else: patience_counter += 1 if patience_counter >= self.early_stopping_patience: log.info( " Early stopping at epoch %d/%d (no improvement for %d epochs)", epoch_idx + 1, self.epochs, self.early_stopping_patience, ) stopped_early = True log.info( " epoch %d/%d loss=%.6f val_loss=%.6f lr=%.2e elapsed=%.1fs", epoch_idx + 1, self.epochs, avg_loss, val_loss, optimizer.param_groups[0]["lr"], time.time() - epoch_start, ) if stopped_early: break if best_state is not None: m.load_state_dict(best_state) return m model = _train_on(device) def _has_nonfinite_params(m) -> bool: for p in m.parameters(): if not torch.isfinite(p).all(): return True return False if device.type != "cpu" and _has_nonfinite_params(model): import warnings warnings.warn( f"CNN training on device={device.type} produced non-finite " "parameters; retraining on CPU.", RuntimeWarning, stacklevel=2, ) device = torch.device("cpu") model = _train_on(device) model.eval() model = model.to("cpu") dummy = torch.zeros(1, self.lookback, n_features, dtype=torch.float32) batch_dim = torch.export.Dim("batch") onnx_program = torch.onnx.export( model, (dummy,), input_names=[ONNX_INPUT_NAME], output_names=["output"], dynamic_shapes={"x": {0: batch_dim}}, opset_version=18, dynamo=True, ) assert onnx_program is not None # dynamo=True always returns an ONNXProgram onnx_proto = onnx_program.model_proto rng = np.random.default_rng(self.random_seed) sanity_x = rng.standard_normal((4, self.lookback, n_features)).astype( np.float32 ) with torch.no_grad(): torch_out = model(torch.from_numpy(sanity_x)).cpu().numpy() sess_options = rt.SessionOptions() restricted_threads = get_restricted_threads() if restricted_threads is not None: sess_options.intra_op_num_threads = restricted_threads onnx_sess = rt.InferenceSession( onnx_proto.SerializeToString(), sess_options=sess_options, providers=["CPUExecutionProvider"], ) onnx_out = onnx_sess.run(None, {ONNX_INPUT_NAME: sanity_x})[0] export_drift_rtol = 1e-3 export_drift_atol = 1e-4 if not np.allclose( onnx_out, torch_out, rtol=export_drift_rtol, atol=export_drift_atol, ): abs_diff = np.abs(torch_out - onnx_out) max_abs = float(np.max(abs_diff)) scale = np.maximum(np.abs(torch_out), np.abs(onnx_out)) max_rel = float(np.max(abs_diff / np.maximum(scale, export_drift_atol))) raise RuntimeError( "ONNX export drift exceeded tolerance: " f"max|Δ|={max_abs:.2e}, max relative drift={max_rel:.2e}, " f"rtol={export_drift_rtol:.1e}, atol={export_drift_atol:.1e}" ) return ONNXEstimator( onnx_proto, input_spec=InputSpec( lookback=self.lookback, grouping_column=self.grouping_column, pad_strategy=self.pad_strategy, ), )
def _CNN1D( n_features: int, hidden_channels: int, kernel_size: int, n_conv_layers: int, n_targets: int, feat_mean: np.ndarray, feat_std: np.ndarray, ): """A 1D CNN that maps (N, T, F) → (N, n_targets) for sequence length T. Stack of ``n_conv_layers`` VALID-padded kernel-K Conv1D → ReLU layers, an adaptive average pool over any remaining sequence positions, and a single linear head ``hidden → n_targets``. """ import torch from torch import nn mean_tensor = torch.from_numpy(feat_mean.astype(np.float32)).view(1, n_features, 1) std_tensor = torch.from_numpy(feat_std.astype(np.float32)).view(1, n_features, 1) class CNN(nn.Module): feat_mean: torch.Tensor feat_std: torch.Tensor def __init__(self): super().__init__() self.register_buffer("feat_mean", mean_tensor) self.register_buffer("feat_std", std_tensor) layers: list[nn.Module] = [] in_ch = n_features for _ in range(n_conv_layers): layers.append(nn.Conv1d(in_ch, hidden_channels, kernel_size, padding=0)) layers.append(nn.ReLU()) in_ch = hidden_channels self.conv = nn.Sequential(*layers) self.pool = nn.AdaptiveAvgPool1d(1) self.head = nn.Linear(hidden_channels, n_targets) def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (N, T, F) → (N, F, T) for Conv1d x = x.transpose(1, 2) x = (x - self.feat_mean) / self.feat_std x = self.conv(x) x = self.pool(x).squeeze(-1) return self.head(x) return CNN()