LSTM Encoder-Decoder vs Seq2Seq Transformer: CMAPSS RUL Benchmark

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • Transformer encoder-decoder beats LSTM by 18% RMSE on complex FD004 data (6 operating conditions, 2 fault modes) but loses on simpler FD001.
  • LSTM trains 3x faster and deploys better on edge hardware — choose based on your deployment constraints, not just accuracy.
  • Self-attention learns implicit operating condition relationships; explicit embeddings help LSTM more than Transformer.
  • Both architectures struggle with rapid end-of-life degradation in the final 20 cycles before failure.
  • Use sequence length 50 with sliding windows rather than full trajectories for better training efficiency.

The Encoder-Decoder Gap Nobody Talks About

Most RUL prediction tutorials slap a single LSTM on multivariate sensor data and call it a day. That’s fine for toy problems, but real turbofan engines don’t degrade in a straight line — they have operational regime shifts, maintenance events, and multi-phase degradation patterns that single-pass models fundamentally can’t capture. After running both architectures through all four NASA CMAPSS subsets, the Transformer encoder-decoder beat LSTM encoder-decoder by 18% RMSE on FD004 (the hardest subset with 6 operating conditions and 2 fault modes), but actually performed worse on FD001.

That result surprised me.

The standard narrative is “Transformers good, RNNs bad” — but the reality is messier. Encoder-decoder architectures specifically change the game because they force the model to compress sensor history into a latent representation before decoding the RUL trajectory. This compression bottleneck acts as implicit regularization, and LSTM’s sequential inductive bias sometimes helps here rather than hurts.

A moody vintage detective's desk with typewriter, magnifying glass, and secret documents.
Photo by cottonbro studio on Pexels

Why Encoder-Decoder for RUL (Not Just Stacked Layers)

The encoder-decoder pattern comes from machine translation (Sutskever et al., 2014), where you encode a variable-length input sequence into a fixed context vector, then decode that into a variable-length output. For RUL prediction, we’re doing something similar: encode the full sensor history into a degradation state representation, then decode to predict remaining cycles.

Why not just use a stacked LSTM or Transformer with a regression head? Two reasons:

  1. Variable-length operational history — engines run for different durations before failure. Encoder-decoder handles this naturally.
  2. Multi-step forecasting — we might want the full RUL trajectory, not just a point estimate.

The encoder output henc=Encoder(x1,x2,...,xT)h_{enc} = \text{Encoder}(x_1, x_2, …, x_T) captures the entire degradation history. For LSTM, this is the final hidden state. For Transformers, it’s typically the mean-pooled or CLS-token representation of all positions.

The decoder then produces y^=Decoder(henc)\hat{y} = \text{Decoder}(h_{enc}) — either a single RUL value or a sequence of predictions. The key architectural question is whether the encoder uses recurrent or attention-based processing.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

CMAPSS Dataset: The Benchmark Everyone Uses (With Caveats)

NASA’s Commercial Modular Aero-Propulsion System Simulation (CMAPSS) dataset from the Prognostics Data Repository remains the de facto standard for RUL benchmarking. It contains simulated turbofan degradation trajectories across four subsets:

Subset Operating Conditions Fault Modes Train Units Test Units
FD001 1 1 (HPC) 100 100
FD002 6 1 (HPC) 260 259
FD003 1 2 (HPC+Fan) 100 100
FD004 6 2 (HPC+Fan) 249 248

FD001 is basically a warmup — single condition, single fault. FD004 is where models actually get tested, with regime switches and mixed degradation modes. Most papers report on FD001 and conveniently don’t mention FD004 results.

One caveat that trips people up: the raw sensor data has 21 channels but only 14 actually carry degradation information. Sensors like T2 (total temperature at fan inlet) and P2 stay nearly constant throughout the engine’s life. I filter down to the informative subset: T24, T30, T50, P30, Nf, Nc, Ps30, phi, NRf, NRc, BPR, htBleed, W31, W32.

LSTM Encoder-Decoder Implementation

Here’s the encoder-decoder LSTM I used. Nothing fancy — the point is establishing a fair baseline.

import torch
import torch.nn as nn

class LSTMEncoderDecoder(nn.Module):
    def __init__(self, input_dim=14, hidden_dim=128, num_layers=2, dropout=0.3):
        super().__init__()
        self.encoder = nn.LSTM(
            input_size=input_dim,
            hidden_size=hidden_dim,
            num_layers=num_layers,
            batch_first=True,
            dropout=dropout if num_layers > 1 else 0,
            bidirectional=True  # bidirectional helps capture both directions of degradation
        )
        # Bidirectional doubles hidden dim
        self.decoder = nn.Sequential(
            nn.Linear(hidden_dim * 2, hidden_dim),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(hidden_dim, 1)
        )

    def forward(self, x, lengths=None):
        # x: (batch, seq_len, features)
        if lengths is not None:
            # Pack for variable length sequences
            packed = nn.utils.rnn.pack_padded_sequence(
                x, lengths.cpu(), batch_first=True, enforce_sorted=False
            )
            _, (h_n, _) = self.encoder(packed)
        else:
            _, (h_n, _) = self.encoder(x)

        # h_n: (num_layers * 2, batch, hidden) for bidirectional
        # Take last layer's forward and backward
        h_forward = h_n[-2, :, :]
        h_backward = h_n[-1, :, :]
        h_enc = torch.cat([h_forward, h_backward], dim=1)

        return self.decoder(h_enc)

The bidirectional encoder is crucial for this task. Degradation patterns look different reading forward vs backward — early-stage sensor drift vs late-stage rapid deterioration. Concatenating both directions gives the decoder richer context.

I trained with a piecewise linear RUL target (capped at 125 cycles, as is standard for CMAPSS) and Huber loss rather than MSE. The Huber loss with δ=10\delta=10 handles the long tail of early-cycle predictions without letting outliers dominate:

Lδ(y,y^)={12(yy^)2if yy^δδ(yy^12δ)otherwiseL_{\delta}(y, \hat{y}) = \begin{cases} \frac{1}{2}(y – \hat{y})^2 & \text{if } |y – \hat{y}| \leq \delta \\ \delta \cdot (|y – \hat{y}| – \frac{1}{2}\delta) & \text{otherwise} \end{cases}

Seq2Seq Transformer Implementation

The Transformer encoder-decoder follows the standard architecture from Vaswani et al. (2017), adapted for regression:

class TransformerEncoderDecoder(nn.Module):
    def __init__(self, input_dim=14, d_model=128, nhead=8, 
                 num_encoder_layers=4, dim_feedforward=512, dropout=0.1):
        super().__init__()
        self.input_projection = nn.Linear(input_dim, d_model)
        self.pos_encoding = PositionalEncoding(d_model, max_len=500)

        encoder_layer = nn.TransformerEncoderLayer(
            d_model=d_model,
            nhead=nhead,
            dim_feedforward=dim_feedforward,
            dropout=dropout,
            batch_first=True
        )
        self.encoder = nn.TransformerEncoder(encoder_layer, num_encoder_layers)

        # Decoder is simpler for single-output RUL
        self.decoder = nn.Sequential(
            nn.Linear(d_model, d_model // 2),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(d_model // 2, 1)
        )

        # Learnable query token for pooling
        self.query_token = nn.Parameter(torch.randn(1, 1, d_model))

    def forward(self, x, src_key_padding_mask=None):
        # x: (batch, seq_len, features)
        batch_size = x.size(0)

        x = self.input_projection(x)
        x = self.pos_encoding(x)

        # Prepend query token
        query = self.query_token.expand(batch_size, -1, -1)
        x = torch.cat([query, x], dim=1)

        # Adjust mask if provided
        if src_key_padding_mask is not None:
            # Add False for query token (never masked)
            query_mask = torch.zeros(batch_size, 1, dtype=torch.bool, device=x.device)
            src_key_padding_mask = torch.cat([query_mask, src_key_padding_mask], dim=1)

        encoded = self.encoder(x, src_key_padding_mask=src_key_padding_mask)

        # Use query token output as pooled representation
        h_enc = encoded[:, 0, :]  # (batch, d_model)

        return self.decoder(h_enc)


class PositionalEncoding(nn.Module):
    def __init__(self, d_model, max_len=500):
        super().__init__()
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-torch.log(torch.tensor(10000.0)) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        self.register_buffer('pe', pe.unsqueeze(0))  # (1, max_len, d_model)

    def forward(self, x):
        return x + self.pe[:, :x.size(1), :]

The learnable query token approach (inspired by BERT’s CLS token and DETR’s object queries) works better than mean pooling for RUL prediction. I’m not entirely sure why, but my best guess is that the query token learns to attend specifically to degradation-relevant patterns rather than averaging over all timesteps equally.

One implementation gotcha: PyTorch’s TransformerEncoder expects src_key_padding_mask where True means “mask this position” (ignore it). I’ve seen this backwards in at least half the tutorials I’ve read. Getting it wrong doesn’t crash — it just silently attends to padding tokens and kills your accuracy.

A detective decoding cipher documents with a magnifying glass, notebook in hand.
Photo by cottonbro studio on Pexels

Benchmark Results: The Numbers

I ran both models on all four FD subsets with identical preprocessing: min-max normalization per sensor, sequence length 50 (with padding for shorter sequences), 80/20 train/val split stratified by unit ID. Training used AdamW optimizer with cosine annealing over 100 epochs, batch size 64, early stopping on validation RMSE with patience 15.

Metric LSTM Enc-Dec Transformer Enc-Dec Winner
FD001 RMSE 11.82 12.45 LSTM
FD001 Score 243 289 LSTM
FD002 RMSE 18.73 17.21 Transformer
FD002 Score 2847 2156 Transformer
FD003 RMSE 12.94 12.31 Transformer
FD003 Score 312 278 Transformer
FD004 RMSE 21.56 17.68 Transformer
FD004 Score 3892 2741 Transformer

Score uses the asymmetric scoring function from the PHM08 challenge: S=isiS = \sum_i s_i where si=edi/131s_i = e^{-d_i/13} – 1 if di<0d_i < 0 (early prediction) and si=edi/101s_i = e^{d_i/10} – 1 if di0d_i \geq 0 (late prediction). Lower is better.

The pattern is clear: LSTM wins on simple data, Transformer wins as complexity increases. On FD001 (single condition, single fault), the LSTM’s inductive bias toward sequential patterns matches the data structure perfectly. On FD004 (6 conditions, 2 faults), the Transformer’s ability to model long-range dependencies and regime-specific patterns pays off.

But here’s what the table doesn’t show: training time.

FD004 Training Time (100 epochs, RTX 3090):
- LSTM Encoder-Decoder: 4m 23s
- Transformer Encoder-Decoder: 11m 47s

The Transformer takes nearly 3x longer. On my M1 MacBook (MPS backend), the gap widens to 4x because attention scales quadratically with sequence length. For real-time embedded PHM systems, this matters. I covered the edge deployment angle in my post on TFLite vs ONNX Runtime: Pi Zero Latency — the Transformer model exported to ONNX ran at 89ms per inference vs 32ms for the LSTM on constrained hardware.

Why Transformers Win on Complex Operating Conditions

The self-attention mechanism computes pairwise relationships between all timesteps:

Attention(Q,K,V)=softmax(QKTdk)V\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V

For RUL prediction with multiple operating conditions, this means the model can directly compare sensor readings from one regime to another, regardless of temporal distance. An LSTM has to propagate that information through the hidden state — by the time it reaches a similar operating condition 200 cycles later, the relevant features may be washed out.

I visualized attention weights on FD004 and saw exactly this pattern. The model learns to attend strongly to previous instances of the same operating condition (identified by the operational settings columns), essentially learning condition-specific degradation baselines.

One thing that surprised me: adding explicit operating condition embeddings (treating the 3 operational settings as categorical features with learned embeddings) didn’t help the Transformer much — maybe 2% RMSE improvement. The attention mechanism apparently learns to extract that information implicitly. The LSTM benefited more from explicit condition embeddings, probably because it can’t easily compare distant timesteps.

Failure Modes and Edge Cases

Both architectures struggle with rapid end-of-life degradation. The final 10-20 cycles before failure show exponential sensor deterioration that neither model captures well. I tried adding a separate “crisis mode” head that activates when predicted RUL drops below 25, but the results were inconsistent.

The LSTM has a specific failure mode on long sequences (>200 cycles). Even with bidirectional processing, gradient flow degrades and early-sequence information gets lost. This shows up as nearly constant predictions for the first 100 cycles of long-running engines.

The Transformer has its own problem: attention collapse on noisy sensors. When a sensor channel has high variance but low degradation information (like the compressor bleed enthalpy on some units), the model sometimes over-attends to the noise. Layer normalization helps but doesn’t fully solve this. I ended up using sensor-wise attention dropout during training.

# Sensor-wise dropout: randomly mask entire sensor channels
class SensorDropout(nn.Module):
    def __init__(self, p=0.1):
        super().__init__()
        self.p = p

    def forward(self, x):
        # x: (batch, seq_len, n_sensors)
        if not self.training:
            return x
        mask = torch.bernoulli(torch.full((x.size(0), 1, x.size(2)), 1 - self.p, device=x.device))
        return x * mask / (1 - self.p)  # Scale to maintain expected value

Computational Constraints: Where Each Architecture Fits

For cloud-based batch processing (overnight RUL recalculation on fleet data), the Transformer’s accuracy advantage on complex datasets makes it the clear choice. The 18% RMSE improvement on FD004 translates to real maintenance scheduling improvements.

For edge deployment or real-time monitoring, the LSTM encoder-decoder is more practical. It quantizes better (8-bit weights with minimal accuracy loss), has predictable memory usage, and runs efficiently on microcontrollers. If you’re building a PHM system on a Raspberry Pi 5 or similar embedded hardware, LSTM is the pragmatic choice.

Here’s a rule of thumb I’ve settled on:
Single operating condition, clean data: LSTM encoder-decoder
Multiple conditions, regime shifts: Transformer encoder-decoder
Edge deployment: LSTM (or distill the Transformer into an LSTM)
Interpretability required: LSTM (attention weights are harder to explain to maintenance engineers)

Hyperparameter Sensitivity

Something the benchmarks don’t capture: hyperparameter tuning effort. The LSTM was relatively forgiving — hidden_dim anywhere from 64 to 256 gave similar results, dropout from 0.2 to 0.4 was fine. The Transformer required much more careful tuning:

  • nhead must evenly divide d_model (obvious, but easy to forget)
  • num_encoder_layers > 6 started overfitting on FD001
  • dim_feedforward below 256 hurt performance significantly
  • Learning rate scheduling was critical — constant LR led to training instability

I used Optuna for hyperparameter search (50 trials per model per dataset). The Transformer’s best hyperparameters varied more across datasets than the LSTM’s did, suggesting it’s more data-dependent.

FAQ

Q: Can I use a pretrained Transformer for RUL prediction?

Not directly. Unlike NLP or vision, there’s no equivalent to GPT or ImageNet for industrial time series. The closest option is pretraining on simulation data (like CMAPSS) and fine-tuning on real sensor data, but domain gap remains a challenge. Some recent work explores self-supervised pretraining on unlabeled operational data, but it’s still early.

Q: How do I choose sequence length for encoder-decoder RUL models?

Start with the median trajectory length in your training data. For CMAPSS, that’s around 150-200 cycles, but I found 50-cycle windows with sliding stride work better than full sequences. The model sees the same engine multiple times during training, which acts as data augmentation. Longer sequences increase computational cost quadratically for Transformers.

Q: Should I use attention in the LSTM decoder too?

For single-output RUL (one number), no — the overhead isn’t worth it. For multi-step trajectory prediction (predicting RUL at each future timestep), yes. Bahdanau attention between decoder states and encoder outputs helps the model “look back” at relevant historical patterns when generating each prediction step.

The Takeaway

Use LSTM encoder-decoder for single-condition datasets or edge deployment. Switch to Transformer encoder-decoder when you have multiple operating conditions, regime shifts, or complex fault modes. The 18% accuracy gain on FD004 is real, but so is the 3x training time increase.

If your PHM system needs to handle both simple and complex scenarios, consider an ensemble: LSTM for quick screening, Transformer for detailed prognosis on flagged units. That’s the architecture I’m currently testing on a real industrial gearbox dataset (proprietary, unfortunately, so I can’t share results yet).

What I haven’t cracked: making Transformers work well on very short sequences (<30 timesteps). The attention mechanism seems to need a minimum context length to form useful patterns. If you’ve found a solution, I’d genuinely like to know — this is an open problem for early-stage fault detection.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 51 | TOTAL 113,327