Predictive maintenance and continuous condition monitoring have become critical pillars of modern smart manufacturing. From drive belts and electric motors to cutting tools and high-speed bearings, the cost of unexpected machine downtime far exceeds the investment in automated fault detection systems.
However, moving from raw industrial sensor signals (such as 3-axis high-frequency accelerometer data) to actionable health classifications is notoriously challenging. Industrial environments are noisy, operating loads fluctuate dynamically, and degradation signatures evolve across both microsecond shock pulses and minutes-long progressive trends.
In this guide, we will build a production-ready Hybrid 1D-CNN–BiLSTM architecture in PyTorch tailored specifically for multi-channel time-series condition monitoring. This approach directly reflects practical methodologies developed in recent manufacturing research, combining local transient feature extraction with long-range sequential memory.
The Core Problem: Why Classical Approaches Fall Short
Traditional condition monitoring often relies on either:
- Manual statistical indicators: Root Mean Square (RMS), peak-to-peak amplitude, crest factor, or kurtosis.
- Frequency-domain analysis: Fast Fourier Transform (FFT) or envelope spectra to identify characteristic defect frequencies.
While effective under steady-state conditions, these methods degrade when:
- Machines run under variable speeds and dynamic loads, which smear spectral peaks across frequency bins.
- Background noise from adjacent workshop equipment masks subtle harmonic anomalies.
- Sensor signatures require complex multi-sensor fusion (e.g., combining 3-axis vibrations with temperature or torque).
Pure deep learning offers an end-to-end alternative, but selecting the right neural architecture is key.
Why a Hybrid CNN–BiLSTM Architecture?
When processing raw continuous time-series, both pure CNNs and pure RNNs exhibit distinct trade-offs:
- 1D Convolutional Neural Networks (1D-CNNs) excel at extracting local temporal patterns, filter banks, and high-frequency shock signatures (such as the impact of a bearing roller passing a defect). They are fast and parallelizable, but their receptive field is inherently localized; they struggle with long-term temporal context.
- Long Short-Term Memory networks (LSTMs) are designed to maintain internal memory over time. However, feeding raw high-frequency sensor streams (e.g., 10 kHz vibration signals sampled over several seconds) directly into an LSTM is computationally intractable and prone to vanishing gradients over thousands of raw timesteps.
The Synergy: Best of Both Worlds
A hybrid architecture stacks these models sequentially:
[ Raw Multi-Channel Sensor Input ] (Batch, Channels, Window Length)
│
▼
[ 1D-CNN Feature Extractor ] Local spatial-temporal features, noise reduction, downsampling
│
▼
[ Bidirectional LSTM ] Temporal sequence modeling (forward & backward context)
│
▼
[ Attention / Pooling ] Aggregates sequence representations
│
▼
[ Dense / Softmax Head ] Fault Class / Health State Probability
- The 1D-CNN acts as a learnable feature extractor: It processes raw multi-channel waveforms, strips high-frequency stochastic noise, downsamples the sequence along the time axis, and constructs rich local feature vectors.
- The Bidirectional LSTM (BiLSTM) reasons over the abstracted sequence: By evaluating the compressed feature maps in both forward (past-to-future) and backward (future-to-past) temporal directions, the BiLSTM captures degradation trajectories and inter-state transitions.
- The Dense Head predicts the condition: Outputs probabilities across discrete machine states (e.g.,
Normal,Slight Wear,Misalignment,Critical Failure).
Step 1: Data Preparation & Sliding Windows
Vibration datasets are typically continuous multi-channel signals. We segment continuous time streams into fixed-length windows using an overlapping sliding window technique.
import torch
from torch.utils.data import Dataset, DataLoader
import numpy as np
from typing import Tuple, List
class SensorTimeSeriesDataset(Dataset):
"""
Multi-channel sensor time-series dataset with overlapping sliding windows.
Input shape per window: (num_channels, window_size)
"""
def __init__(
self,
signals: np.ndarray,
labels: np.ndarray,
window_size: int = 1024,
step_size: int = 256
):
"""
Args:
signals: Array of shape (total_timesteps, num_channels)
labels: Array of shape (total_timesteps,)
window_size: Number of timesteps per sample window
step_size: Stride for overlapping windows
"""
self.windows = []
self.targets = []
num_timesteps = signals.shape[0]
for start in range(0, num_timesteps - window_size + 1, step_size):
end = start + window_size
window = signals[start:end, :] # Shape: (window_size, num_channels)
# Use majority label within the window as ground truth
label = int(np.bincount(labels[start:end].astype(int)).argmax())
# PyTorch Conv1d expects (channels, sequence_length)
self.windows.append(window.T.astype(np.float32))
self.targets.append(label)
self.windows = np.array(self.windows)
self.targets = np.array(self.targets)
def __len__(self) -> int:
return len(self.windows)
def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
x = torch.from_numpy(self.windows[idx])
y = torch.tensor(self.targets[idx], dtype=torch.long)
return x, y
Tip on Normalization: Normalize each sensor channel using z-score normalization (
(x - mean) / std) calculated strictly from the training partition to prevent test data leakage.
Step 2: Defining the Hybrid CNN–BiLSTM Model
Below is the PyTorch model definition. Notice how the output of the convolutional block is transposed to match the (batch, seq_len, features) layout expected by PyTorch's nn.LSTM(batch_first=True).
import torch
import torch.nn as nn
class CNNBiLSTMClassifier(nn.Module):
def __init__(
self,
in_channels: int = 3, # e.g., 3-axis accelerometer (X, Y, Z)
num_classes: int = 4, # e.g., Normal, Loose, Worn, Damaged
conv_filters: list = [32, 64, 128],
lstm_hidden: int = 64,
lstm_layers: int = 2,
dropout: float = 0.3
):
super().__init__()
# --- Stage 1: 1D-CNN Feature Extractor ---
layers = []
current_channels = in_channels
for filters in conv_filters:
layers.extend([
nn.Conv1d(
in_channels=current_channels,
out_channels=filters,
kernel_size=7,
stride=1,
padding=3
),
nn.BatchNorm1d(filters),
nn.ReLU(inplace=True),
nn.MaxPool1d(kernel_size=2, stride=2), # Halves temporal dimension
nn.Dropout(dropout / 2)
])
current_channels = filters
self.cnn = nn.Sequential(*layers)
# --- Stage 2: Bidirectional LSTM ---
self.lstm = nn.LSTM(
input_size=conv_filters[-1],
hidden_size=lstm_hidden,
num_layers=lstm_layers,
batch_first=True,
bidirectional=True,
dropout=dropout if lstm_layers > 1 else 0.0
)
# --- Stage 3: Classification Head ---
# Since it's bidirectional, the hidden state size is doubled (2 * lstm_hidden)
self.classifier = nn.Sequential(
nn.Linear(lstm_hidden * 2, 64),
nn.ReLU(inplace=True),
nn.Dropout(dropout),
nn.Linear(64, num_classes)
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x shape: (batch_size, in_channels, sequence_length)
# 1. Convolutional feature maps
features = self.cnn(x)
# features shape: (batch_size, conv_filters[-1], reduced_seq_len)
# 2. Reshape for LSTM: (batch_size, reduced_seq_len, conv_filters[-1])
features = features.permute(0, 2, 1)
# 3. BiLSTM temporal modeling
lstm_out, _ = self.lstm(features)
# lstm_out shape: (batch_size, reduced_seq_len, lstm_hidden * 2)
# 4. Global Temporal Pooling (Mean Pooling across timesteps)
pooled = torch.mean(lstm_out, dim=1)
# pooled shape: (batch_size, lstm_hidden * 2)
# 5. Class prediction logits
logits = self.classifier(pooled)
return logits
Tensor Shape Breakdown
To verify how dimensions transform through the network, imagine a batch of 16 samples with 3 sensor channels and a window length of 1024 timesteps:
| Layer | Input Shape | Output Shape | Notes |
|---|---|---|---|
| Input | (16, 3, 1024) |
— | 3 axes, 1024 samples |
| Conv Block 1 | (16, 3, 1024) |
(16, 32, 512) |
MaxPool halves time dimension |
| Conv Block 2 | (16, 32, 512) |
(16, 64, 256) |
Kernel size 7, stride 1 |
| Conv Block 3 | (16, 64, 256) |
(16, 128, 128) |
128 extracted feature maps |
| Permute | (16, 128, 128) |
(16, 128, 128) |
Swaps channels and sequence axes |
| BiLSTM (2 layers) | (16, 128, 128) |
(16, 128, 128) |
hidden_size=64 * 2 directions |
| Mean Pool | (16, 128, 128) |
(16, 128) |
Averages representations over time |
| Linear Head | (16, 128) |
(16, 4) |
4 class logits |
Step 3: Handling Class Imbalance During Training
In real industrial condition monitoring, machines spend 95%–99% of their lifetime in healthy states. Failure instances are rare. If trained naively with standard Cross-Entropy loss, the model will quickly learn to always predict "Healthy", achieving 98% nominal accuracy while failing completely at fault detection.
We address this with inverse frequency class weighting:
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from sklearn.metrics import classification_report, f1_score
import numpy as np
def compute_class_weights(labels: np.ndarray, num_classes: int) -> torch.Tensor:
"""Compute balanced class weights for CrossEntropyLoss."""
class_counts = np.bincount(labels, minlength=num_classes)
total_samples = len(labels)
# Inverse frequency weighting
weights = total_samples / (num_classes * np.maximum(class_counts, 1))
return torch.tensor(weights, dtype=torch.float32)
def train_epoch(
model: nn.Module,
dataloader: DataLoader,
criterion: nn.Module,
optimizer: torch.optim.Optimizer,
device: torch.device
) -> float:
model.train()
total_loss = 0.0
for batch_x, batch_y in dataloader:
batch_x = batch_x.to(device)
batch_y = batch_y.to(device)
optimizer.zero_grad()
predictions = model(batch_x)
loss = criterion(predictions, batch_y)
loss.backward()
# Gradient clipping prevents exploding gradients in LSTMs
nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
total_loss += loss.item() * batch_x.size(0)
return total_loss / len(dataloader.dataset)
def evaluate(
model: nn.Module,
dataloader: DataLoader,
device: torch.device
) -> Tuple[float, np.ndarray, np.ndarray]:
model.eval()
all_preds = []
all_targets = []
with torch.no_grad():
for batch_x, batch_y in dataloader:
batch_x = batch_x.to(device)
logits = model(batch_x)
preds = torch.argmax(logits, dim=1).cpu().numpy()
all_preds.extend(preds)
all_targets.extend(batch_y.numpy())
macro_f1 = f1_score(all_targets, all_preds, average='macro')
return macro_f1, np.array(all_targets), np.array(all_preds)
Step 4: Edge Deployment via ONNX Export
Industrial facilities rarely transmit raw high-frequency vibration data directly to cloud APIs due to bandwidth constraints and latency requirements. The trained model must run at the edge on shop-floor industrial PCs (IPCs) or microcontrollers.
PyTorch makes exporting to ONNX straightforward:
import torch
from src.model import CNNBiLSTMClassifier
def export_model_to_onnx(
checkpoint_path: str,
onnx_output_path: str,
window_size: int = 1024,
num_channels: int = 3
):
model = CNNBiLSTMClassifier(in_channels=num_channels, num_classes=4)
model.load_state_dict(torch.load(checkpoint_path, map_location="cpu"))
model.eval()
dummy_input = torch.randn(1, num_channels, window_size, requires_grad=False)
torch.onnx.export(
model,
dummy_input,
onnx_output_path,
export_params=True,
opset_version=14,
do_constant_folding=True,
input_names=["sensor_window"],
output_names=["fault_logits"],
dynamic_axes={
"sensor_window": {0: "batch_size"},
"fault_logits": {0: "batch_size"}
}
)
print(f"✅ Model successfully exported to {onnx_output_path}")
if __name__ == "__main__":
export_model_to_onnx("best_model.pt", "condition_monitor.onnx")
Once exported, the .onnx model can be run natively in C++ or lightweight Python runtimes using onnxruntime, achieving sub-10 millisecond inference times even on standard industrial edge hardware without a dedicated GPU.
Summary and Key Takeaways
- Local + Global Representation: Raw high-frequency signals require hierarchical processing. 1D-CNNs handle local feature discovery and noise suppression, while BiLSTMs track state progression over time.
- Global Mean Pooling Over Sequence Timesteps: Rather than using only the final LSTM hidden state, pooling across all sequence states provides richer gradients and greater resilience to sporadic shock transients.
- Class Balancing Matters Most: In machine diagnostics, always monitor Macro F1-Score and Recall on minority fault classes rather than raw accuracy.
- Edge Ready: By downsampling within the CNN stage, the recurrent layer processes significantly fewer timesteps, allowing the hybrid model to run reliably on edge hardware.
References & Further Reading
- Research Paper: Sahib, M. M., Plänitz, P., Hackert-Oschätzchen, M., & Lerez, C. (2026). Condition Monitoring Model Development for Belt Systems Using Hybrid CNN–BiLSTM Deep-Learning Techniques. Machines, 14(3), 348. https://doi.org/10.3390/machines14030348
- Related Work on Tool Wear: Lerez, C., Petermann, R., Felhö, C., Plänitz, P., et al. (2026). Surface Roughness Prediction in Turning Stainless Steel Applying Deep Learning and LSTM Networks. Procedia Computer Science, 277, 159–168. https://doi.org/10.1016/j.procs.2026.02.057
- PyTorch Documentation: torch.nn.Conv1d and torch.nn.LSTM
- ONNX Runtime: High-performance inferencing on edge devices