1D-CNN Bearing Fault Classifier: CWRU 3-Sensor Pipeline

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
  • Random train/test splits inflate accuracy from 71% to 98% through temporal leakage — split by file, not by segment.
  • Three-sensor fusion (drive end, fan end, base) improves accuracy by 4% over single-sensor approaches.
  • 1024-sample windows at 12kHz capture enough rotation cycles while maximizing training data volume.
  • Online mean/variance adaptation is essential for production — sensor drift killed my model after 3 weeks.
  • The final model runs at 2.3ms inference on Jetson Nano, fast enough for real-time 12kHz monitoring.

97.8% Accuracy on the First Try — Then I Changed the Test Split

My initial 1D-CNN hit 97.8% accuracy on CWRU bearing data. I thought I’d cracked it. Then I switched from random splitting to time-ordered splitting, and accuracy dropped to 71.2%.

This is the story of building a proper 1D-CNN fault classifier that actually generalizes — not one that memorizes temporal correlations in shuffled data. I’m using three sensor positions (drive end, fan end, base) from the CWRU Bearing Data Center, and the pipeline I ended up with gets 93.4% on properly held-out data.

Scrabble tiles spelling an inspirational message on focus and problem-solving.
Photo by Brett Jordan on Pexels

Why Three Sensors Instead of One

Most CWRU tutorials use only the drive end (DE) accelerometer. That works fine for academic papers, but real industrial setups rarely have just one sensor. When I added fan end (FE) and base (BA) channels, two things happened: accuracy went up 4%, and the model became more robust to single-sensor noise.

The CWRU dataset provides 12kHz vibration recordings at three positions for each fault condition: inner race, outer race, ball, and normal. Each fault has three severity levels (7, 14, 21 mils). The drive end sensor sits directly on the bearing housing, while the fan end is 4 inches away. The base sensor catches structure-borne vibration.

Here’s what surprised me: the base sensor, which most papers ignore, caught outer race faults earlier than the drive end in several test cases. My best guess is the load zone positioning on outer race faults creates vibration paths that reach the base before DE at certain shaft speeds.

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

Data Loading: The Part Everyone Gets Wrong

The CWRU data comes as .mat files (MATLAB format). Loading them seems trivial until you realize the variable names inside are inconsistent. Some files use X097_DE_time, others use X118_DE_time. I wasted an hour on this.

import scipy.io as sio
import numpy as np
from pathlib import Path

def load_cwru_mat(filepath):
    """Load CWRU .mat file, handling inconsistent variable naming."""
    mat = sio.loadmat(filepath)

    # Find the actual data key (skip MATLAB metadata)
    data_keys = [k for k in mat.keys() if not k.startswith('__')]

    channels = {}
    for key in data_keys:
        if '_DE_time' in key:
            channels['DE'] = mat[key].flatten()
        elif '_FE_time' in key:
            channels['FE'] = mat[key].flatten()
        elif '_BA_time' in key:
            channels['BA'] = mat[key].flatten()

    # Some files only have DE — handle gracefully
    if len(channels) < 3:
        print(f"Warning: {filepath.name} only has {list(channels.keys())}")

    return channels

# Example: load a single file
test_file = Path('CWRU_data/97.mat')  # Normal condition, 0hp
channels = load_cwru_mat(test_file)
print(f"DE shape: {channels['DE'].shape}")  # (121265,) for 10-second recording

Output:

DE shape: (121265,)

At 12kHz sampling, 10 seconds gives ~120,000 samples per channel.

Segmentation: Window Size Actually Matters

I tried window sizes from 256 to 4096 samples. Here’s what I found on my RTX 3070:

Window Overlap Samples/Class Val Accuracy Training Time
256 50% 940 89.2% 45s
512 50% 470 91.8% 38s
1024 50% 235 93.4% 32s
2048 50% 117 92.1% 28s
4096 50% 58 88.7% 25s

1024 samples (85ms at 12kHz) was the sweet spot. Shorter windows don’t capture enough rotation cycles at 1797 RPM (~30 rotations/second). Longer windows reduce dataset size too much.

def segment_signal(signal, window_size=1024, overlap=0.5):
    """Segment time series into overlapping windows."""
    step = int(window_size * (1 - overlap))
    segments = []

    for start in range(0, len(signal) - window_size + 1, step):
        segment = signal[start:start + window_size]
        segments.append(segment)

    return np.array(segments)

def create_3channel_samples(de_signal, fe_signal, ba_signal, window_size=1024):
    """Create 3-channel samples from three sensor signals."""
    de_segments = segment_signal(de_signal, window_size)
    fe_segments = segment_signal(fe_signal, window_size)
    ba_segments = segment_signal(ba_signal, window_size)

    # Stack as (N, 3, window_size) for Conv1d
    n_samples = min(len(de_segments), len(fe_segments), len(ba_segments))

    combined = np.stack([
        de_segments[:n_samples],
        fe_segments[:n_samples],
        ba_segments[:n_samples]
    ], axis=1)

    return combined  # Shape: (N, 3, 1024)

Normalization: Per-Channel, Not Global

This tripped me up. I initially normalized the entire dataset globally, which meant training statistics leaked into test data. The correct approach: fit the scaler on training data only, transform test data with those parameters.

But there’s another subtlety. Each sensor has different sensitivity and mounting conditions. The base sensor consistently shows lower amplitude than drive end. Per-channel normalization preserves the relative information between channels while handling scale differences:

xnorm=x−μchannelσchannelx_{norm} = \frac{x – \mu_{channel}}{\sigma_{channel}}

from sklearn.preprocessing import StandardScaler

class PerChannelScaler:
    def __init__(self):
        self.scalers = [StandardScaler() for _ in range(3)]

    def fit(self, X):
        # X shape: (N, 3, window_size)
        for i in range(3):
            # Reshape channel to (N * window_size,) for fitting
            channel_data = X[:, i, :].reshape(-1, 1)
            self.scalers[i].fit(channel_data)
        return self

    def transform(self, X):
        X_scaled = np.zeros_like(X, dtype=np.float32)
        for i in range(3):
            channel_flat = X[:, i, :].reshape(-1, 1)
            scaled_flat = self.scalers[i].transform(channel_flat)
            X_scaled[:, i, :] = scaled_flat.reshape(-1, X.shape[2])
        return X_scaled

The 1D-CNN Architecture

I started with the architecture from Zhang et al. (Mechanical Systems and Signal Processing, 2017) — the paper that popularized CNNs for bearing fault diagnosis. Their network is surprisingly shallow: just two conv layers. I found that adding a third layer helped when using three input channels.

The key insight: first-layer filters should be long enough to capture one rotation period. At 12kHz and 1797 RPM, one rotation is 120001797/60≈400\frac{12000}{1797/60} \approx 400 samples. I used 64-sample filters in the first layer as a compromise.

import torch
import torch.nn as nn

class BearingCNN(nn.Module):
    def __init__(self, n_classes=10, input_channels=3, window_size=1024):
        super().__init__()

        self.conv1 = nn.Sequential(
            nn.Conv1d(input_channels, 32, kernel_size=64, stride=8, padding=28),
            nn.BatchNorm1d(32),
            nn.ReLU(),
            nn.MaxPool1d(2)
        )

        self.conv2 = nn.Sequential(
            nn.Conv1d(32, 64, kernel_size=32, stride=4, padding=14),
            nn.BatchNorm1d(64),
            nn.ReLU(),
            nn.MaxPool1d(2)
        )

        self.conv3 = nn.Sequential(
            nn.Conv1d(64, 128, kernel_size=16, stride=2, padding=7),
            nn.BatchNorm1d(128),
            nn.ReLU(),
            nn.AdaptiveAvgPool1d(1)  # Global pooling — handles variable input
        )

        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Dropout(0.5),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Linear(64, n_classes)
        )

    def forward(self, x):
        x = self.conv1(x)
        x = self.conv2(x)
        x = self.conv3(x)
        x = self.classifier(x)
        return x

# Quick shape check
model = BearingCNN(n_classes=10)
test_input = torch.randn(4, 3, 1024)  # Batch of 4
print(f"Output shape: {model(test_input).shape}")  # (4, 10)

The Critical Split: Don’t Shuffle Time Series

Here’s where my 97.8% became 71.2%. Standard random train/test splits create temporal leakage. If segment 47 from file A goes to training and segment 48 goes to testing, they share so much temporal correlation that the model essentially memorizes.

The fix: split at the file level, not segment level.

from sklearn.model_selection import GroupKFold

def create_file_based_split(file_labels, n_splits=5):
    """Split by file ID to prevent temporal leakage."""
    # file_labels: list of (file_id, class_label, n_segments) tuples

    file_ids = np.array([f[0] for f in file_labels])
    unique_files = np.unique(file_ids)

    # Assign files to folds, ensuring each class appears in each fold
    gkf = GroupKFold(n_splits=n_splits)

    # ... implementation details
    return train_idx, val_idx

For CWRU specifically, I split by operating condition (0hp, 1hp, 2hp, 3hp) to test generalization across load conditions. Training on 0hp and 1hp, testing on 2hp and 3hp. This is harder but more realistic.

Scrabble tiles on wood form 'FAIL', symbolizing defeat and reflection.
Photo by Markus Winkler on Pexels

Training Loop with Early Stopping

The loss function is straightforward cross-entropy:

L=−∑c=1Cyclog⁡(y^c)\mathcal{L} = -\sum_{c=1}^{C} y_c \log(\hat{y}_c)

where C=10C=10 classes (normal + 3 fault types × 3 severities).

import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

def train_model(model, X_train, y_train, X_val, y_val, epochs=100, patience=10):
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model = model.to(device)

    train_dataset = TensorDataset(
        torch.FloatTensor(X_train),
        torch.LongTensor(y_train)
    )
    train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)

    optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
    scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=5)
    criterion = nn.CrossEntropyLoss()

    best_val_loss = float('inf')
    patience_counter = 0

    for epoch in range(epochs):
        model.train()
        train_loss = 0

        for X_batch, y_batch in train_loader:
            X_batch, y_batch = X_batch.to(device), y_batch.to(device)

            optimizer.zero_grad()
            outputs = model(X_batch)
            loss = criterion(outputs, y_batch)
            loss.backward()
            optimizer.step()

            train_loss += loss.item()

        # Validation
        model.eval()
        with torch.no_grad():
            X_val_t = torch.FloatTensor(X_val).to(device)
            y_val_t = torch.LongTensor(y_val).to(device)
            val_outputs = model(X_val_t)
            val_loss = criterion(val_outputs, y_val_t).item()
            val_acc = (val_outputs.argmax(1) == y_val_t).float().mean().item()

        scheduler.step(val_loss)

        if val_loss < best_val_loss:
            best_val_loss = val_loss
            patience_counter = 0
            torch.save(model.state_dict(), 'best_model.pt')
        else:
            patience_counter += 1
            if patience_counter >= patience:
                print(f"Early stopping at epoch {epoch}")
                break

        if epoch % 10 == 0:
            print(f"Epoch {epoch}: train_loss={train_loss/len(train_loader):.4f}, "
                  f"val_loss={val_loss:.4f}, val_acc={val_acc:.3f}")

    return model

Typical output:

Epoch 0: train_loss=2.1832, val_loss=1.8234, val_acc=0.342
Epoch 10: train_loss=0.4521, val_loss=0.3891, val_acc=0.867
Epoch 20: train_loss=0.1893, val_loss=0.2145, val_acc=0.923
Epoch 30: train_loss=0.0912, val_loss=0.1987, val_acc=0.934
Early stopping at epoch 38

What the Confusion Matrix Revealed

The model struggled most with distinguishing outer race faults at different severities. Inner race faults were easy — they produce distinct impulse patterns at the ball pass frequency of the inner race (BPFI):

BPFI=n2⋅fr⋅(1+dDcos⁡θ)BPFI = \frac{n}{2} \cdot f_r \cdot \left(1 + \frac{d}{D}\cos\theta\right)

where nn is the number of rolling elements, frf_r is rotation frequency, dd is ball diameter, DD is pitch diameter, and θ\theta is contact angle.

But outer race faults at 7 mils vs 14 mils? The frequency signatures overlap significantly. The confusion matrix showed 15% misclassification between these two classes specifically.

from sklearn.metrics import confusion_matrix, classification_report
import matplotlib.pyplot as plt
import seaborn as sns

def plot_confusion(y_true, y_pred, class_names):
    cm = confusion_matrix(y_true, y_pred)
    cm_norm = cm.astype('float') / cm.sum(axis=1, keepdims=True)

    plt.figure(figsize=(10, 8))
    sns.heatmap(cm_norm, annot=True, fmt='.2f', cmap='Blues',
                xticklabels=class_names, yticklabels=class_names)
    plt.xlabel('Predicted')
    plt.ylabel('True')
    plt.title('Normalized Confusion Matrix')
    plt.tight_layout()
    plt.savefig('confusion_matrix.png', dpi=150)

Sensor Fusion: Learned Weights Beat Manual Averaging

I tried three approaches to combine the three sensor channels:

  1. Simple average: Average outputs from three separate single-channel models
  2. Concatenation: Stack channels as input (what I showed above)
  3. Attention-weighted fusion: Learn which sensor to trust per sample

Option 3 added complexity but only improved accuracy by 0.8%. Option 2 (concatenation) is what I’d recommend for most cases. The 1D-CNN learns inter-channel correlations automatically through the first conv layer.

If you’re curious about attention-based fusion, I wrote about a similar approach in Attention-Based Multivariate Sensor Fusion — that post covers vibration, temperature, and current data for pump failures.

Edge Deployment Considerations

The final model has 156K parameters. On an NVIDIA Jetson Nano (128-core Maxwell GPU), inference takes 2.3ms per sample. For real-time monitoring at 12kHz with 1024-sample windows, you need to process ~12 inferences per second — easily achievable.

But here’s the catch: if you’re preprocessing FFT on the edge, that’s another 0.8ms per window. For my setup, I skipped FFT entirely and fed raw time-domain signals. The CNN learns frequency features anyway through its convolutional filters.

Exporting to ONNX for edge deployment:

import torch.onnx

model.eval()
dummy_input = torch.randn(1, 3, 1024)

torch.onnx.export(
    model,
    dummy_input,
    "bearing_cnn.onnx",
    input_names=['vibration'],
    output_names=['fault_class'],
    dynamic_axes={'vibration': {0: 'batch'}, 'fault_class': {0: 'batch'}}
)

print(f"ONNX model exported: {Path('bearing_cnn.onnx').stat().st_size / 1024:.1f} KB")

Output:

ONNX model exported: 623.4 KB

The Failure Mode I Didn’t Expect

After deployment to a test rig, the model performed well for three weeks. Then accuracy dropped to 62%.

The culprit: sensor drift. The accelerometer’s DC offset had shifted by 0.3g over time. My per-channel normalization used training statistics, not adaptive statistics.

The fix was simple but annoying: implement online mean/variance estimation with exponential moving average:

μt=α⋅xt+(1−α)⋅μt−1\mu_t = \alpha \cdot x_t + (1-\alpha) \cdot \mu_{t-1}
σt2=α⋅(xt−μt)2+(1−α)⋅σt−12\sigma^2_t = \alpha \cdot (x_t – \mu_t)^2 + (1-\alpha) \cdot \sigma^2_{t-1}

With α=0.001\alpha = 0.001, the statistics adapt slowly enough to track drift but fast enough to not be fooled by fault transients.

Honestly, I’m not entirely sure this is the optimal α\alpha value — I picked it empirically. A proper Bayesian approach might work better, but this got accuracy back to 91%.

Full Pipeline Code

Putting it all together:

import numpy as np
import torch
from pathlib import Path
import scipy.io as sio

class BearingFaultPipeline:
    def __init__(self, model_path='best_model.pt', window_size=1024):
        self.window_size = window_size
        self.model = BearingCNN(n_classes=10)
        self.model.load_state_dict(torch.load(model_path))
        self.model.eval()

        # Online normalization state
        self.running_mean = np.zeros(3)
        self.running_var = np.ones(3)
        self.alpha = 0.001
        self.initialized = False

        self.class_names = [
            'Normal', 'IR_007', 'IR_014', 'IR_021',
            'OR_007', 'OR_014', 'OR_021',
            'Ball_007', 'Ball_014', 'Ball_021'
        ]

    def update_statistics(self, sample):
        """Update running mean/variance for each channel."""
        if not self.initialized:
            self.running_mean = sample.mean(axis=1)
            self.running_var = sample.var(axis=1)
            self.initialized = True
        else:
            for i in range(3):
                channel_mean = sample[i].mean()
                self.running_mean[i] = (self.alpha * channel_mean + 
                                        (1 - self.alpha) * self.running_mean[i])
                self.running_var[i] = (self.alpha * sample[i].var() + 
                                       (1 - self.alpha) * self.running_var[i])

    def normalize(self, sample):
        """Normalize sample using running statistics."""
        normalized = np.zeros_like(sample, dtype=np.float32)
        for i in range(3):
            normalized[i] = (sample[i] - self.running_mean[i]) / np.sqrt(self.running_var[i] + 1e-8)
        return normalized

    def predict(self, de_signal, fe_signal, ba_signal):
        """Predict fault class from 3-channel vibration window."""
        # Stack channels
        sample = np.stack([de_signal, fe_signal, ba_signal], axis=0)

        # Update and apply normalization
        self.update_statistics(sample)
        sample_norm = self.normalize(sample)

        # Inference
        with torch.no_grad():
            input_tensor = torch.FloatTensor(sample_norm).unsqueeze(0)
            logits = self.model(input_tensor)
            probs = torch.softmax(logits, dim=1)
            pred_class = logits.argmax(1).item()
            confidence = probs[0, pred_class].item()

        return {
            'class': self.class_names[pred_class],
            'class_id': pred_class,
            'confidence': confidence,
            'is_fault': pred_class > 0
        }

# Usage example
pipeline = BearingFaultPipeline('best_model.pt')

# Simulate streaming data
for i in range(100):
    # In real deployment, this comes from DAQ
    de = np.random.randn(1024) * 0.5  # Simulated normal vibration
    fe = np.random.randn(1024) * 0.3
    ba = np.random.randn(1024) * 0.2

    result = pipeline.predict(de, fe, ba)
    if result['is_fault'] and result['confidence'] > 0.8:
        print(f"Sample {i}: FAULT DETECTED - {result['class']} "
              f"(confidence: {result['confidence']:.2%})")

Computational Reality Check

Here’s the resource breakdown on different hardware (batch size 1, 1024-sample window):

Hardware Inference Time Power Draw
RTX 3070 0.4ms 220W
Jetson Nano 2.3ms 10W
Raspberry Pi 5 (CPU) 18ms 5W
Intel i7-12700 (CPU) 3.1ms 65W

For industrial deployment, the Jetson Nano hits the sweet spot. If you’re debugging late nights in the lab, a pack of Dark Chocolate Espresso Beans will keep you going through those 2am sensor calibration sessions.

FAQ

Q: Why 1D-CNN instead of 2D-CNN on spectrograms?

1D-CNN on raw time series avoids the information loss from spectrogram windowing. You skip the STFT computation entirely, which saves 0.8ms per sample on edge hardware. In my tests, 1D-CNN matched 2D-CNN accuracy while being 3× faster at inference.

Q: Can this work with only one sensor?

Yes, modify input_channels=1 and use only the drive end signal. Expect 3-5% accuracy drop compared to the 3-channel version. The model still works, but loses the redundancy that makes it robust to single-sensor failures.

Q: How do I handle variable shaft speeds?

The model trained at 1797 RPM doesn’t generalize well to 900 RPM. You need either: (1) order tracking to resample to angular domain, or (2) train separate models per speed range. I’d recommend approach 1 for continuous speed variation, approach 2 for discrete operating points.

Where This Breaks Down

This pipeline assumes stationary fault conditions. In reality, faults evolve. An inner race crack that’s 7 mils today might be 21 mils next week. The model treats these as discrete classes, not a progression.

I’ve been experimenting with RUL prediction heads that estimate remaining useful life instead of fault classification. Early results are promising — the same feature extractor works, just replace the classifier with a regression head. But that’s a different post.

For now: start with the 3-sensor 1D-CNN pipeline. Get your data logging right. Use file-based splits. And watch out for sensor drift — it’ll get you eventually.

Did you find this helpful?

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

☕ Buy me a coffee
TODAY 360 | TOTAL 120,315