Predict Turbofan Engine Remaining Life
Build an end-to-end ML pipeline predicting jet engine RUL from NASA sensor data.
Introduction
30 Second Summary
Imagine a jet engine failing mid-flight because no one predicted it was about to break down. In the real world, airlines and power plants rely on sensor data to predict when equipment will fail before it actually does.
In this project, you will build a machine learning pipeline that predicts how many operational cycles a turbofan engine has left before failure, using real NASA sensor data from 100 engines that ran until they broke.
What You'll Build
A predictive maintenance system that ingests multivariate sensor data, engineers degradation-capturing features, trains an XGBoost model, and serves real-time predictions through a FastAPI REST API.
By the end of this project, you'll have:
- A trained XGBoost model that predicts remaining engine life from 14 active sensor readings, evaluated with RMSE and MAE metrics.
- A complete ML pipeline in a Jupyter Notebook covering data ingestion, feature engineering, model training, and evaluation.
- A locally-running FastAPI prediction endpoint that accepts sensor readings and returns a RUL prediction with a maintenance alert flag.
- Secret Mission: Add SHAP explainability to identify which sensors are the strongest failure predictors and generate a beeswarm plot.
Are there any prerequisites?
This project assumes you have Python 3.13 installed, along with Jupyter Notebook.
Familiarity with pandas and scikit-learn basics (DataFrames, fitting a model) will help you follow along smoothly.
Set Up the Project and Load Data
To build a predictive maintenance model, you need real degradation data from engines that ran until failure. NASA's C-MAPSS dataset provides exactly this: multivariate sensor readings from 100 turbofan engines, recorded every operational cycle until each engine broke down.
In this step, you will download the dataset, create your notebook, and load the raw data into pandas DataFrames ready for exploration.
In this step, get ready to:
- Download and extract the NASA C-MAPSS FD001 dataset.
- Create the project notebook and import all required libraries.
- Load the training, test, and RUL data into DataFrames.
Download the NASA C-MAPSS dataset
The C-MAPSS (Commercial Modular Aero-Propulsion System Simulation) dataset is NASA's gold-standard benchmark for prognostics research. The FD001 subset contains 100 engines running under a single operating condition (sea level) with a single fault mode (high-pressure compressor degradation).
- Open this download link in your browser:
- https://phm-datasets.s3.amazonaws.com/NASA/6.+Turbofan+Engine+Degradation+Simulation+Data+Set.zip
- Once the ZIP file downloads, extract its contents.
- Inside the extracted folder, locate the files train_FD001.txt, test_FD001.txt, and RUL_FD001.txt.
- Copy these three files into the data/ folder in your project directory.
- Confirm the files are in place by running this in your terminal:
ls data/
You should see train_FD001.txt, test_FD001.txt, and RUL_FD001.txt listed in the output.
What is the C-MAPSS dataset?
Each .txt file is a space-separated table with no headers. The training file contains sensor readings from 100 engines that each ran until failure. The test file contains partial trajectories that stop before failure. The RUL file contains the true remaining cycles for each test engine's final reading.
Each row has 26 columns: unit number, time in cycles, 3 operational settings, and 21 sensor measurements. You will assign meaningful column names when you load the data.
Download not working?
If your browser blocks the download or the link times out, try right-clicking the link and selecting Save Link As. The file is approximately 3 MB.
help me troubleshoot downloading the NASA C-MAPSS dataset
Create the notebook and import libraries
With the data files in place, you need a Jupyter Notebook to build the entire ML pipeline. This first cell imports every library you will use across the project and creates the output directories for saved models.
- In Jupyter Notebook, create a new Python notebook and name it rul_prediction.ipynb.
- In the first cell, paste the following imports and setup code:
# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_squared_error, mean_absolute_error, precision_score, recall_score, f1_score
from xgboost import XGBRegressor
import joblib
import os
os.makedirs('models', exist_ok=True)
os.makedirs('api', exist_ok=True)
What does this code do?
The imports bring in the full toolkit for this project: pandas for data handling, NumPy for numerical operations, matplotlib for plotting degradation curves, scikit-learn for preprocessing and metrics, and XGBoost for the gradient-boosted regression model.
The os.makedirs calls create models/ and api/ directories (if they don't already exist) so later steps can save model artifacts and the FastAPI application files.
- Run the cell by pressing Shift+Enter.
- Confirm the cell runs without any import errors.
Seeing a ModuleNotFoundError?
If you see an error like ModuleNotFoundError: No module named 'xgboost', one of the packages did not install correctly in the previous step.
Return to your terminal and re-run the install command from Step 1. Then restart the Jupyter kernel by clicking Kernel then Restart in the menu bar.
help me fix a ModuleNotFoundError in my Jupyter Notebook
Load the dataset into DataFrames
The raw text files have no column headers. You need to define the 26 column names yourself and tell pandas to use whitespace as the delimiter.
- In a new cell (Cell 2), paste the following code to load all three data files:
# Cell 2: Load Data
column_names = ['unit_number', 'time_in_cycles', 'operational_setting_1',
'operational_setting_2', 'operational_setting_3'] + \
[f'sensor_measurement_{i}' for i in range(1, 22)]
train_df = pd.read_csv('data/train_FD001.txt', sep=r'\s+', header=None, names=column_names)
test_df = pd.read_csv('data/test_FD001.txt', sep=r'\s+', header=None, names=column_names)
rul_df = pd.read_csv('data/RUL_FD001.txt', sep=r'\s+', header=None, names=['RUL'])
print(f"Training data: {train_df.shape}")
print(f"Unique engines: {train_df['unit_number'].nunique()}")
What does this code do?
The column_names list assigns meaningful labels to all 26 columns: unit_number identifies the engine, time_in_cycles tracks the operational cycle count, and the remaining columns capture 3 operational settings plus 21 sensor measurements.
The sep=r'\s+' argument tells pandas to split on any whitespace (spaces or tabs), which matches the file format. Setting header=None tells pandas there is no header row in the file.
Loading rul_df separately gives you the ground-truth Remaining Useful Life values for the test engines. You will use these later for model evaluation.
- Run the cell by pressing Shift+Enter.
- You should see output showing Training data: (20631, 26) and Unique engines: 100.
Seeing a FileNotFoundError?
This means pandas cannot find the data files. Check that you placed train_FD001.txt, test_FD001.txt, and RUL_FD001.txt directly inside the data/ folder in the same directory where your notebook is saved.
Also confirm your Jupyter Notebook's working directory matches your project folder. You can check by running os.getcwd() in a cell.
help me fix a FileNotFoundError when loading the C-MAPSS data files
✔️ Awesome, I've got everything!
Great. Double check you have saved your notebook with both cells running cleanly.
ⓧ I'd like to double check the full code
Here is the complete code for rul_prediction.ipynb so far:
# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_squared_error, mean_absolute_error, precision_score, recall_score, f1_score
from xgboost import XGBRegressor
import joblib
import os
os.makedirs('models', exist_ok=True)
os.makedirs('api', exist_ok=True)
# Cell 2: Load Data
column_names = ['unit_number', 'time_in_cycles', 'operational_setting_1',
'operational_setting_2', 'operational_setting_3'] + \
[f'sensor_measurement_{i}' for i in range(1, 22)]
train_df = pd.read_csv('data/train_FD001.txt', sep=r'\s+', header=None, names=column_names)
test_df = pd.read_csv('data/test_FD001.txt', sep=r'\s+', header=None, names=column_names)
rul_df = pd.read_csv('data/RUL_FD001.txt', sep=r'\s+', header=None, names=['RUL'])
print(f"Training data: {train_df.shape}")
print(f"Unique engines: {train_df['unit_number'].nunique()}")
Your data is loaded and ready for analysis. Next up, you will explore degradation patterns in the sensor readings and engineer the RUL target labels.
Explore and Preprocess the Data
In Step 1, you loaded 20,000+ rows of turbofan sensor data into a DataFrame. Now it's time to understand what that data is actually telling you.
Raw sensor readings are noisy. Not all 21 sensors carry useful degradation signals. In this step, you'll visualize how engines deteriorate over time, remove uninformative sensors, compute the target variable (Remaining Useful Life), and normalize everything for model training.
In this step, get ready to:
- Plot sensor degradation curves to identify useful signals.
- Compute and cap Remaining Useful Life labels.
- Normalize sensor readings for model training.
Explore degradation patterns
Each engine in the dataset runs from healthy to failure. If a sensor captures real degradation, its readings should drift consistently as the engine approaches end-of-life.
Plotting a few engines reveals which sensors carry signal and which are flat noise.
- In your notebook, create a new cell (Cell 3) and add the following code to plot degradation curves for three sample engines:
# Cell 3: EDA - Plot Sensor Degradation
fig, axes = plt.subplots(3, 1, figsize=(12, 8))
sample_engines = [1, 5, 10]
sensor_to_plot = 'sensor_measurement_11'
for ax, engine_id in zip(axes, sample_engines):
engine_data = train_df[train_df['unit_number'] == engine_id]
ax.plot(engine_data['time_in_cycles'], engine_data[sensor_to_plot])
ax.set_title(f'Engine {engine_id} - {sensor_to_plot}')
ax.set_xlabel('Cycles')
ax.set_ylabel('Sensor Value')
plt.tight_layout()
plt.savefig('models/degradation_curves.png', dpi=100, bbox_inches='tight')
plt.show()
What does this code do?
This cell plots sensor_measurement_11 over time for engines 1, 5, and 10. Each subplot shows one engine's full lifecycle from healthy operation to failure.
Sensor 11 is a good choice because it shows a clear upward drift as the engine degrades. The plot also saves to models/degradation_curves.png so you can reference it later.
- Run the cell.
- You should see three line plots showing sensor values rising over the engine's lifecycle. Each line ends at a different cycle count (the engine's failure point).
Seeing a blank plot or no output?
Make sure you ran Cell 1 (imports) and Cell 2 (data loading) first. The cells depend on train_df, plt, and np being defined.
If you see the plot saved but nothing displayed inline, check that your notebook is set to display matplotlib figures inline (this is the default for Jupyter).
help me debug my matplotlib plot not showing in Jupyter
Not all sensors show that clear upward drift. Some sensors output a nearly constant value across the entire lifecycle. These flat sensors carry no degradation information and would only add noise to the model.
- Create a new cell (Cell 4) and add this code to identify and remove constant sensors using variance analysis:
# Cell 4: Remove Constant Sensors
sensor_cols = [f'sensor_measurement_{i}' for i in range(1, 22)]
sensor_variance = train_df[sensor_cols].var()
constant_sensors = sensor_variance[sensor_variance < 0.001].index.tolist()
print(f"Removing constant sensors: {constant_sensors}")
active_sensors = [col for col in sensor_cols if col not in constant_sensors]
print(f"Active sensors ({len(active_sensors)}): {active_sensors}")
What does this code do?
The .var() method computes the variance of each sensor column across all rows. A variance below 0.001 means the sensor barely changes. It's effectively constant.
Sensors 1, 5, 6, 10, 16, 18, and 19 fall below this threshold. Removing them reduces noise and leaves ~14 active sensors that actually capture degradation dynamics.
- Run the cell.
- You should see a printed list of 7 constant sensors being removed and a confirmation of ~14 active sensors remaining.
Seeing a different number of constant sensors?
If you see more or fewer than 7 sensors removed, double-check that you loaded the correct file (train_FD001.txt) and that Cell 2 ran without errors. The variance threshold of 0.001 is standard for FD001.
help me understand why my sensor variance results are different
Compute RUL labels and apply piecewise-linear capping
Your model needs a target variable to predict. For each row (one cycle of one engine), the target is how many cycles remain before that engine fails.
There's a subtlety here. An engine with 300 cycles left is just as "healthy" as one with 200 cycles left. The model only needs to distinguish degradation levels once failure gets close. Capping the RUL at 125 cycles focuses learning on the critical zone.
- Create a new cell (Cell 5) and add this code to compute RUL labels with a piecewise-linear cap:
# Cell 5: Compute RUL with Piecewise-Linear Cap
max_cycles = train_df.groupby('unit_number')['time_in_cycles'].max().reset_index()
max_cycles.columns = ['unit_number', 'max_cycle']
train_df = train_df.merge(max_cycles, on='unit_number')
train_df['RUL'] = train_df['max_cycle'] - train_df['time_in_cycles']
RUL_CAP = 125
train_df['RUL'] = train_df['RUL'].clip(upper=RUL_CAP)
train_df = train_df.drop(columns=['max_cycle'])
What does this code do?
First, it finds each engine's maximum cycle (its failure point). Then it computes RUL as the countdown: max_cycle - current_cycle. An engine at cycle 50 with a max of 200 gets RUL = 150.
The .clip(upper=RUL_CAP) caps every value above 125 to exactly 125. This is the standard approach in Prognostics and Health Management literature (Heimes, 2008). It prevents the model from wasting capacity distinguishing between "very healthy" and "extremely healthy."
- Run the cell.
- Verify by running train_df['RUL'].describe() in a quick check. The max value should be 125.0 and the min should be 0.
RUL max is higher than 125?
Make sure the RUL_CAP = 125 line and the .clip(upper=RUL_CAP) line are both present. If you ran the cell multiple times, the merge might have duplicated rows. Restart the kernel and run all cells from the top.
help me fix my RUL computation
Normalize sensor readings
Different sensors operate on different scales. One might range from 0 to 600 while another ranges from 1.0 to 1.5. Without normalization, the model would weight large-scale sensors more heavily regardless of their actual predictive value.
- Create a new cell (Cell 6) and add this code to normalize all active sensor columns and operational settings:
# Cell 6: Normalize Sensors
feature_cols = active_sensors + ['operational_setting_1', 'operational_setting_2', 'operational_setting_3']
scaler = MinMaxScaler()
train_df[feature_cols] = scaler.fit_transform(train_df[feature_cols])
joblib.dump(scaler, 'models/scaler.joblib')
What does this code do?
The MinMaxScaler rescales each column to a 0-1 range based on its minimum and maximum values in the training data. It's fitted only on training data to prevent information leakage from test data.
The fitted scaler is saved to models/scaler.joblib because the API endpoint will need the exact same transformation later when processing incoming sensor readings.
- Run the cell.
- Verify by running train_df[feature_cols].describe() in a quick check. The min values should be 0.0 and the max values should be 1.0 for every column.
Values outside the 0-1 range?
If you see values outside 0-1, the scaler might have been fitted on a subset of data. Ensure feature_cols includes only the active sensors (not the constant ones you removed). Also confirm you're using fit_transform and not just transform on the training data.
help me debug my MinMaxScaler normalization
Why fit only on training data?
If you fit the scaler on both train and test data, the model indirectly "sees" information from the test set during training. This is called data leakage. It inflates your evaluation metrics and hides how the model will perform on truly unseen data.
Later, you'll apply this same scaler (with transform only, not fit_transform) to the test set and to live API requests.
✔️ Awesome, I've got everything!
Great. Double check you've saved your notebook before moving on.
ⓧ I'd like to double check the full code
Here is the full notebook code up to this point. Compare it against your cells:
# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_squared_error, mean_absolute_error, precision_score, recall_score, f1_score
from xgboost import XGBRegressor
import joblib
import os
os.makedirs('models', exist_ok=True)
os.makedirs('api', exist_ok=True)
# Cell 2: Load Data
column_names = ['unit_number', 'time_in_cycles', 'operational_setting_1',
'operational_setting_2', 'operational_setting_3'] + \
[f'sensor_measurement_{i}' for i in range(1, 22)]
train_df = pd.read_csv('data/train_FD001.txt', sep=r'\s+', header=None, names=column_names)
test_df = pd.read_csv('data/test_FD001.txt', sep=r'\s+', header=None, names=column_names)
rul_df = pd.read_csv('data/RUL_FD001.txt', sep=r'\s+', header=None, names=['RUL'])
print(f"Training data: {train_df.shape}")
print(f"Unique engines: {train_df['unit_number'].nunique()}")
# Cell 3: EDA - Plot Sensor Degradation
fig, axes = plt.subplots(3, 1, figsize=(12, 8))
sample_engines = [1, 5, 10]
sensor_to_plot = 'sensor_measurement_11'
for ax, engine_id in zip(axes, sample_engines):
engine_data = train_df[train_df['unit_number'] == engine_id]
ax.plot(engine_data['time_in_cycles'], engine_data[sensor_to_plot])
ax.set_title(f'Engine {engine_id} - {sensor_to_plot}')
ax.set_xlabel('Cycles')
ax.set_ylabel('Sensor Value')
plt.tight_layout()
plt.savefig('models/degradation_curves.png', dpi=100, bbox_inches='tight')
plt.show()
# Cell 4: Remove Constant Sensors
sensor_cols = [f'sensor_measurement_{i}' for i in range(1, 22)]
sensor_variance = train_df[sensor_cols].var()
constant_sensors = sensor_variance[sensor_variance < 0.001].index.tolist()
print(f"Removing constant sensors: {constant_sensors}")
active_sensors = [col for col in sensor_cols if col not in constant_sensors]
print(f"Active sensors ({len(active_sensors)}): {active_sensors}")
# Cell 5: Compute RUL with Piecewise-Linear Cap
max_cycles = train_df.groupby('unit_number')['time_in_cycles'].max().reset_index()
max_cycles.columns = ['unit_number', 'max_cycle']
train_df = train_df.merge(max_cycles, on='unit_number')
train_df['RUL'] = train_df['max_cycle'] - train_df['time_in_cycles']
RUL_CAP = 125
train_df['RUL'] = train_df['RUL'].clip(upper=RUL_CAP)
train_df = train_df.drop(columns=['max_cycle'])
# Cell 6: Normalize Sensors
feature_cols = active_sensors + ['operational_setting_1', 'operational_setting_2', 'operational_setting_3']
scaler = MinMaxScaler()
train_df[feature_cols] = scaler.fit_transform(train_df[feature_cols])
joblib.dump(scaler, 'models/scaler.joblib')
Your data is clean, labeled, and normalized. Next up, you'll engineer rolling-window features that capture short-term degradation dynamics and prepare the train/test split for modeling.
Engineer Features
Your cleaned and normalized sensor data is ready. Now you need to transform these raw readings into features that capture how an engine degrades over time.
A single sensor snapshot tells you where an engine is right now. Rolling window statistics (mean and standard deviation over the last few cycles) reveal trends and volatility in that degradation. In this step, you will build these time-aware features and prepare leakage-free train/test splits.
In this step, get ready to:
- Create rolling window features that capture short-term degradation dynamics.
- Split data by engine ID to prevent temporal leakage.
- Prepare the test set with the same feature pipeline for fair evaluation.
Create rolling window features
A single cycle's sensor reading is noisy. Computing a rolling mean and standard deviation over a short window smooths out noise and exposes the underlying degradation trend.
- In your notebook, create a new cell (Cell 7) and add the following feature engineering function:
# Cell 7: Feature Engineering - Rolling Statistics
WINDOW_SIZE = 5
def engineer_features(df, feature_cols, window_size=5):
"""Create rolling window features grouped by engine."""
engineered = df.copy()
for col in feature_cols:
grouped = engineered.groupby('unit_number')[col]
engineered[f'{col}_rolling_mean'] = grouped.transform(
lambda x: x.rolling(window=window_size, min_periods=1).mean()
)
engineered[f'{col}_rolling_std'] = grouped.transform(
lambda x: x.rolling(window=window_size, min_periods=1).std().fillna(0)
)
# Normalized cycle position
max_cycles = engineered.groupby('unit_number')['time_in_cycles'].transform('max')
engineered['cycle_norm'] = engineered['time_in_cycles'] / max_cycles
return engineered
train_df = engineer_features(train_df, feature_cols, WINDOW_SIZE)
What does this code do?
The engineer_features() function groups data by engine so that rolling calculations never leak across different engines.
- For each active sensor, it computes a 5-cycle rolling mean (smoothed trend) and rolling standard deviation (volatility).
- min_periods=1 ensures early cycles still get a value instead of NaN, even when fewer than 5 previous readings exist.
- cycle_norm captures where the engine sits in its lifecycle (0 = start, 1 = final recorded cycle).
Why rolling windows instead of raw readings?
Raw sensor values bounce around due to measurement noise. A rolling mean reveals whether a sensor is trending upward or downward over several cycles. The rolling standard deviation captures increasing instability, which often precedes failure.
Together these features let the model detect degradation patterns rather than reacting to individual noisy readings.
- Run the cell. This takes a few seconds as it computes rolling statistics across all 100 engines.
Cell taking too long or throwing a MemoryError?
If you have many browser tabs open, close a few to free up RAM. The rolling computation creates two new columns per active sensor (about 28 new columns total), which temporarily doubles memory usage during the transform.
help me debug a slow or memory-intensive rolling window computation in pandas
Split training and validation data by engine
Splitting by row would let the model peek at later cycles of the same engine during training. Splitting by engine ID keeps each engine's full trajectory in either training or validation, preventing temporal leakage.
- Create a new cell (Cell 8) and add the following code to split your data:
# Cell 8: Prepare Train/Validation Split by Engine
np.random.seed(42)
all_engines = train_df['unit_number'].unique()
val_engines = np.random.choice(all_engines, size=20, replace=False)
train_engines = np.array([e for e in all_engines if e not in val_engines])
feature_columns = [col for col in train_df.columns
if col not in ['unit_number', 'time_in_cycles', 'RUL']]
X_train = train_df[train_df['unit_number'].isin(train_engines)][feature_columns]
y_train = train_df[train_df['unit_number'].isin(train_engines)]['RUL']
X_val = train_df[train_df['unit_number'].isin(val_engines)][feature_columns]
y_val = train_df[train_df['unit_number'].isin(val_engines)]['RUL']
print(f"Training samples: {X_train.shape[0]}, Validation samples: {X_val.shape[0]}")
print(f"Feature count: {X_train.shape[1]}")
What does this code do?
- np.random.seed(42) makes the random engine selection reproducible every time you run the notebook.
- 20 engines go into validation, 80 stay in training. No engine appears in both sets.
- feature_columns automatically includes all original sensor columns, operational settings, rolling features, and cycle_norm while excluding identifiers and the target.
- Run the cell. You should see output showing the training and validation sample counts plus the total feature count (50+ features).
Feature count lower than expected?
If you see fewer than 50 features, confirm that Cell 7 ran successfully before this cell. The rolling features must exist in train_df before the column filter runs.
help me figure out why my feature count is too low after engineering rolling window features
Prepare the test set
The test set needs the exact same preprocessing pipeline: scaling with the fitted scaler, then the same rolling window features. Without this, the model receives differently shaped input at evaluation time.
- Create a new cell (Cell 9) and add the following code to prepare your test data:
# Cell 9: Prepare Test Set
test_df[feature_cols] = scaler.transform(test_df[feature_cols])
test_df = engineer_features(test_df, feature_cols, WINDOW_SIZE)
# Get last cycle per engine for test evaluation
test_last_cycle = test_df.groupby('unit_number').last().reset_index()
X_test = test_last_cycle[feature_columns]
y_test = rul_df['RUL'].values
y_test_capped = np.clip(y_test, a_min=None, a_max=RUL_CAP)
What does this code do?
- scaler.transform(test_df[feature_cols]) normalizes the test sensors using the same scaler fitted on training data. This prevents data leakage from test statistics.
- test_last_cycle extracts only the final cycle per engine. In the C-MAPSS test set, you predict RUL at the last recorded moment.
- y_test_capped clips ground-truth RUL values at 125 to match the same cap applied during training.
- Run the cell. No output is expected since this cell prepares data silently.
Seeing a KeyError or shape mismatch?
Make sure feature_cols (lowercase, defined in Cell 6) is used for scaling, while feature_columns (defined in Cell 8) is used for selecting the final feature matrix. These are two different variables.
help me fix a KeyError when preparing the test set for my RUL prediction pipeline
✔️ Awesome, I've got everything!
Great. Double check you have saved your notebook before moving on.
ⓧ I'd like to double check the full code
Here is the complete notebook code up to this point:
# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_squared_error, mean_absolute_error, precision_score, recall_score, f1_score
from xgboost import XGBRegressor
import joblib
import os
os.makedirs('models', exist_ok=True)
os.makedirs('api', exist_ok=True)
# Cell 2: Load Data
column_names = ['unit_number', 'time_in_cycles', 'operational_setting_1',
'operational_setting_2', 'operational_setting_3'] + \
[f'sensor_measurement_{i}' for i in range(1, 22)]
train_df = pd.read_csv('data/train_FD001.txt', sep=r'\s+', header=None, names=column_names)
test_df = pd.read_csv('data/test_FD001.txt', sep=r'\s+', header=None, names=column_names)
rul_df = pd.read_csv('data/RUL_FD001.txt', sep=r'\s+', header=None, names=['RUL'])
print(f"Training data: {train_df.shape}")
print(f"Unique engines: {train_df['unit_number'].nunique()}")
# Cell 3: EDA - Plot Sensor Degradation
fig, axes = plt.subplots(3, 1, figsize=(12, 8))
sample_engines = [1, 5, 10]
sensor_to_plot = 'sensor_measurement_11'
for ax, engine_id in zip(axes, sample_engines):
engine_data = train_df[train_df['unit_number'] == engine_id]
ax.plot(engine_data['time_in_cycles'], engine_data[sensor_to_plot])
ax.set_title(f'Engine {engine_id} - {sensor_to_plot}')
ax.set_xlabel('Cycles')
ax.set_ylabel('Sensor Value')
plt.tight_layout()
plt.savefig('models/degradation_curves.png', dpi=100, bbox_inches='tight')
plt.show()
# Cell 4: Remove Constant Sensors
sensor_cols = [f'sensor_measurement_{i}' for i in range(1, 22)]
sensor_variance = train_df[sensor_cols].var()
constant_sensors = sensor_variance[sensor_variance < 0.001].index.tolist()
print(f"Removing constant sensors: {constant_sensors}")
active_sensors = [col for col in sensor_cols if col not in constant_sensors]
print(f"Active sensors ({len(active_sensors)}): {active_sensors}")
# Cell 5: Compute RUL with Piecewise-Linear Cap
max_cycles = train_df.groupby('unit_number')['time_in_cycles'].max().reset_index()
max_cycles.columns = ['unit_number', 'max_cycle']
train_df = train_df.merge(max_cycles, on='unit_number')
train_df['RUL'] = train_df['max_cycle'] - train_df['time_in_cycles']
RUL_CAP = 125
train_df['RUL'] = train_df['RUL'].clip(upper=RUL_CAP)
train_df = train_df.drop(columns=['max_cycle'])
# Cell 6: Normalize Sensors
feature_cols = active_sensors + ['operational_setting_1', 'operational_setting_2', 'operational_setting_3']
scaler = MinMaxScaler()
train_df[feature_cols] = scaler.fit_transform(train_df[feature_cols])
joblib.dump(scaler, 'models/scaler.joblib')
# Cell 7: Feature Engineering - Rolling Statistics
WINDOW_SIZE = 5
def engineer_features(df, feature_cols, window_size=5):
"""Create rolling window features grouped by engine."""
engineered = df.copy()
for col in feature_cols:
grouped = engineered.groupby('unit_number')[col]
engineered[f'{col}_rolling_mean'] = grouped.transform(
lambda x: x.rolling(window=window_size, min_periods=1).mean()
)
engineered[f'{col}_rolling_std'] = grouped.transform(
lambda x: x.rolling(window=window_size, min_periods=1).std().fillna(0)
)
# Normalized cycle position
max_cycles = engineered.groupby('unit_number')['time_in_cycles'].transform('max')
engineered['cycle_norm'] = engineered['time_in_cycles'] / max_cycles
return engineered
train_df = engineer_features(train_df, feature_cols, WINDOW_SIZE)
# Cell 8: Prepare Train/Validation Split by Engine
np.random.seed(42)
all_engines = train_df['unit_number'].unique()
val_engines = np.random.choice(all_engines, size=20, replace=False)
train_engines = np.array([e for e in all_engines if e not in val_engines])
feature_columns = [col for col in train_df.columns
if col not in ['unit_number', 'time_in_cycles', 'RUL']]
X_train = train_df[train_df['unit_number'].isin(train_engines)][feature_columns]
y_train = train_df[train_df['unit_number'].isin(train_engines)]['RUL']
X_val = train_df[train_df['unit_number'].isin(val_engines)][feature_columns]
y_val = train_df[train_df['unit_number'].isin(val_engines)]['RUL']
print(f"Training samples: {X_train.shape[0]}, Validation samples: {X_val.shape[0]}")
print(f"Feature count: {X_train.shape[1]}")
# Cell 9: Prepare Test Set
test_df[feature_cols] = scaler.transform(test_df[feature_cols])
test_df = engineer_features(test_df, feature_cols, WINDOW_SIZE)
# Get last cycle per engine for test evaluation
test_last_cycle = test_df.groupby('unit_number').last().reset_index()
X_test = test_last_cycle[feature_columns]
y_test = rul_df['RUL'].values
y_test_capped = np.clip(y_test, a_min=None, a_max=RUL_CAP)
Your feature matrix is ready with 50+ engineered columns per sample. Next up, you will train a Ridge baseline and an XGBoost model to predict RUL and evaluate which one performs better.
Train and Evaluate Models
Your feature matrix is ready with 50+ engineered columns capturing degradation dynamics. Now it is time to answer the core question: can a model learn to predict how many cycles an engine has left before failure?
You will train two models and compare them. A simple Ridge regression establishes a baseline floor. Then an XGBoost gradient boosting model tries to beat it. Finally, you will reframe the regression output as a binary maintenance alarm to evaluate whether the system is operationally useful.
In this step, get ready to:
- Train a Ridge regression baseline and measure RMSE/MAE.
- Train an XGBoost regressor and compare it against Ridge.
- Evaluate both models as maintenance alarms using precision, recall, and F1-score.
Train a Ridge regression baseline
Before reaching for a complex model, you need a simple baseline. If XGBoost cannot beat a linear model, the extra complexity is not justified.
- In your notebook, create a new cell (Cell 10) and add the following code to train a Ridge regressor and evaluate it on both validation and test sets:
# Cell 10: Train Ridge Baseline
ridge = Ridge(alpha=1.0)
ridge.fit(X_train, y_train)
ridge_pred_val = ridge.predict(X_val)
ridge_pred_test = ridge.predict(X_test)
print("=== Ridge Regression (Baseline) ===")
print(f"Validation RMSE: {np.sqrt(mean_squared_error(y_val, ridge_pred_val)):.2f}")
print(f"Validation MAE: {mean_absolute_error(y_val, ridge_pred_val):.2f}")
print(f"Test RMSE: {np.sqrt(mean_squared_error(y_test_capped, ridge_pred_test)):.2f}")
print(f"Test MAE: {mean_absolute_error(y_test_capped, ridge_pred_test):.2f}")
What does this code do?
Ridge regression is a linear model with L2 regularization. The alpha=1.0 parameter controls how much the model penalizes large coefficients to prevent overfitting.
You predict RUL on both the validation set (engines held out during training) and the test set (entirely unseen engines with ground-truth RUL from RUL_FD001.txt). RMSE penalizes large errors more heavily than MAE, so both metrics together give a fuller picture.
- Run the cell. You should see four metric values printed under the "Ridge Regression (Baseline)" heading.
Seeing a NameError?
Make sure you ran all previous cells in order (Cells 1 through 9). The variables X_train, y_train, X_val, and y_val are defined in Cell 8.
help me debug a NameError in my Ridge training cell
Train an XGBoost regressor
XGBoost builds an ensemble of decision trees, where each new tree corrects the errors of the previous ones. This gradient boosting approach captures non-linear degradation patterns that a linear model cannot.
- Create a new cell (Cell 11) and add the following code to train an XGBoost model and compare its metrics against Ridge:
# Cell 11: Train XGBoost
xgb_model = XGBRegressor(
n_estimators=100,
max_depth=5,
learning_rate=0.1,
objective='reg:squarederror',
random_state=42
)
xgb_model.fit(X_train, y_train)
xgb_pred_val = xgb_model.predict(X_val)
xgb_pred_test = xgb_model.predict(X_test)
print("\n=== XGBoost Regressor ===")
print(f"Validation RMSE: {np.sqrt(mean_squared_error(y_val, xgb_pred_val)):.2f}")
print(f"Validation MAE: {mean_absolute_error(y_val, xgb_pred_val):.2f}")
print(f"Test RMSE: {np.sqrt(mean_squared_error(y_test_capped, xgb_pred_test)):.2f}")
print(f"Test MAE: {mean_absolute_error(y_test_capped, xgb_pred_test):.2f}")
What do these hyperparameters mean?
- n_estimators=100 builds 100 sequential trees in the ensemble.
- max_depth=5 limits tree complexity to prevent overfitting on 100 engines.
- learning_rate=0.1 shrinks each tree's contribution so the ensemble learns gradually.
- objective='reg:squarederror' minimizes mean squared error, the standard loss for regression.
- Run the cell. You should see XGBoost's RMSE and MAE values, both lower than the Ridge baseline printed above.
Why start with a simple baseline?
If Ridge achieved the same accuracy as XGBoost, the extra training time and complexity of gradient boosting would be wasted. Starting simple proves the non-linear model earns its keep by delivering measurably better predictions.
XGBoost not outperforming Ridge?
Confirm that random_state=42 is set in your XGBRegressor. Also verify that your feature engineering cells (Cells 7-9) ran without errors. The rolling statistics are what give XGBoost an edge over the linear model.
help me understand why my XGBoost model is not beating Ridge
Evaluate as a maintenance alarm and save the model
A regression RMSE tells you average prediction error, but maintenance teams need a yes/no answer: should this engine be pulled for service? Reframing the regressor as a binary alarm reveals whether the model is operationally useful.
- Create a new cell (Cell 12) and add the following code to evaluate the model as a maintenance alarm with a 30-cycle threshold:
# Cell 12: Maintenance Alarm Evaluation
ALARM_THRESHOLD = 30
y_test_alarm = (y_test_capped < ALARM_THRESHOLD).astype(int)
xgb_alarm = (xgb_pred_test < ALARM_THRESHOLD).astype(int)
print("\n=== Maintenance Alarm (Threshold = 30 cycles) ===")
print(f"Precision: {precision_score(y_test_alarm, xgb_alarm):.3f}")
print(f"Recall: {recall_score(y_test_alarm, xgb_alarm):.3f}")
print(f"F1-Score: {f1_score(y_test_alarm, xgb_alarm):.3f}")
What do precision and recall mean here?
- Precision: Of all engines the model flagged for maintenance, how many actually needed it? Low precision means unnecessary shutdowns.
- Recall: Of all engines that truly needed maintenance, how many did the model catch? Low recall means missed failures.
- F1-Score: The harmonic mean of precision and recall. It balances both concerns into a single number.
The 30-cycle threshold means any engine predicted to have fewer than 30 cycles remaining gets flagged. This gives maintenance crews enough lead time to schedule a repair.
- Run the cell. You should see Precision, Recall, and F1-Score values printed under the "Maintenance Alarm" heading.
Seeing all zeros for alarm metrics?
If your XGBoost predictions are all above 30, the alarm array will be all zeros and sklearn will warn about undefined precision. Check that xgb_pred_test was computed in Cell 11 and that y_test_capped was defined in Cell 9.
help me debug zero-valued maintenance alarm metrics
- Create one more cell (Cell 13) to serialize the trained model and feature list for deployment:
# Cell 13: Save Model
joblib.dump(xgb_model, 'models/xgb_rul_model.joblib')
joblib.dump(feature_columns, 'models/feature_columns.joblib')
print("Model and artifacts saved to models/")
Why save the feature columns list?
The model expects input features in the exact same order it was trained on. By saving feature_columns alongside the model, your API can validate incoming data against the expected schema at runtime.
- Run the cell. You should see "Model and artifacts saved to models/" printed.
- Confirm the files exist by running this in a new cell:
import os
print(os.listdir('models/'))
- You should see xgb_rul_model.joblib, feature_columns.joblib, scaler.joblib, and degradation_curves.png in the output.
Missing model files?
Make sure Cell 1 ran successfully. It creates the models/ directory with os.makedirs('models', exist_ok=True). If the directory does not exist, joblib will raise a FileNotFoundError.
help me fix a FileNotFoundError when saving my model
✔️ Awesome, I've got everything!
Great. Double check you have saved your notebook with all cells through Cell 13 executed.
ⓧ I'd like to double check the full code
Here is the complete notebook up to this point. Compare your cells against this:
# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_squared_error, mean_absolute_error, precision_score, recall_score, f1_score
from xgboost import XGBRegressor
import joblib
import os
os.makedirs('models', exist_ok=True)
os.makedirs('api', exist_ok=True)
# Cell 2: Load Data
column_names = ['unit_number', 'time_in_cycles', 'operational_setting_1',
'operational_setting_2', 'operational_setting_3'] + \
[f'sensor_measurement_{i}' for i in range(1, 22)]
train_df = pd.read_csv('data/train_FD001.txt', sep=r'\s+', header=None, names=column_names)
test_df = pd.read_csv('data/test_FD001.txt', sep=r'\s+', header=None, names=column_names)
rul_df = pd.read_csv('data/RUL_FD001.txt', sep=r'\s+', header=None, names=['RUL'])
print(f"Training data: {train_df.shape}")
print(f"Unique engines: {train_df['unit_number'].nunique()}")
# Cell 3: EDA - Plot Sensor Degradation
fig, axes = plt.subplots(3, 1, figsize=(12, 8))
sample_engines = [1, 5, 10]
sensor_to_plot = 'sensor_measurement_11'
for ax, engine_id in zip(axes, sample_engines):
engine_data = train_df[train_df['unit_number'] == engine_id]
ax.plot(engine_data['time_in_cycles'], engine_data[sensor_to_plot])
ax.set_title(f'Engine {engine_id} - {sensor_to_plot}')
ax.set_xlabel('Cycles')
ax.set_ylabel('Sensor Value')
plt.tight_layout()
plt.savefig('models/degradation_curves.png', dpi=100, bbox_inches='tight')
plt.show()
# Cell 4: Remove Constant Sensors
sensor_cols = [f'sensor_measurement_{i}' for i in range(1, 22)]
sensor_variance = train_df[sensor_cols].var()
constant_sensors = sensor_variance[sensor_variance < 0.001].index.tolist()
print(f"Removing constant sensors: {constant_sensors}")
active_sensors = [col for col in sensor_cols if col not in constant_sensors]
print(f"Active sensors ({len(active_sensors)}): {active_sensors}")
# Cell 5: Compute RUL with Piecewise-Linear Cap
max_cycles = train_df.groupby('unit_number')['time_in_cycles'].max().reset_index()
max_cycles.columns = ['unit_number', 'max_cycle']
train_df = train_df.merge(max_cycles, on='unit_number')
train_df['RUL'] = train_df['max_cycle'] - train_df['time_in_cycles']
RUL_CAP = 125
train_df['RUL'] = train_df['RUL'].clip(upper=RUL_CAP)
train_df = train_df.drop(columns=['max_cycle'])
# Cell 6: Normalize Sensors
feature_cols = active_sensors + ['operational_setting_1', 'operational_setting_2', 'operational_setting_3']
scaler = MinMaxScaler()
train_df[feature_cols] = scaler.fit_transform(train_df[feature_cols])
joblib.dump(scaler, 'models/scaler.joblib')
# Cell 7: Feature Engineering - Rolling Statistics
WINDOW_SIZE = 5
def engineer_features(df, feature_cols, window_size=5):
"""Create rolling window features grouped by engine."""
engineered = df.copy()
for col in feature_cols:
grouped = engineered.groupby('unit_number')[col]
engineered[f'{col}_rolling_mean'] = grouped.transform(
lambda x: x.rolling(window=window_size, min_periods=1).mean()
)
engineered[f'{col}_rolling_std'] = grouped.transform(
lambda x: x.rolling(window=window_size, min_periods=1).std().fillna(0)
)
# Normalized cycle position
max_cycles = engineered.groupby('unit_number')['time_in_cycles'].transform('max')
engineered['cycle_norm'] = engineered['time_in_cycles'] / max_cycles
return engineered
train_df = engineer_features(train_df, feature_cols, WINDOW_SIZE)
# Cell 8: Prepare Train/Validation Split by Engine
np.random.seed(42)
all_engines = train_df['unit_number'].unique()
val_engines = np.random.choice(all_engines, size=20, replace=False)
train_engines = np.array([e for e in all_engines if e not in val_engines])
feature_columns = [col for col in train_df.columns
if col not in ['unit_number', 'time_in_cycles', 'RUL']]
X_train = train_df[train_df['unit_number'].isin(train_engines)][feature_columns]
y_train = train_df[train_df['unit_number'].isin(train_engines)]['RUL']
X_val = train_df[train_df['unit_number'].isin(val_engines)][feature_columns]
y_val = train_df[train_df['unit_number'].isin(val_engines)]['RUL']
print(f"Training samples: {X_train.shape[0]}, Validation samples: {X_val.shape[0]}")
print(f"Feature count: {X_train.shape[1]}")
# Cell 9: Prepare Test Set
test_df[feature_cols] = scaler.transform(test_df[feature_cols])
test_df = engineer_features(test_df, feature_cols, WINDOW_SIZE)
# Get last cycle per engine for test evaluation
test_last_cycle = test_df.groupby('unit_number').last().reset_index()
X_test = test_last_cycle[feature_columns]
y_test = rul_df['RUL'].values
y_test_capped = np.clip(y_test, a_min=None, a_max=RUL_CAP)
# Cell 10: Train Ridge Baseline
ridge = Ridge(alpha=1.0)
ridge.fit(X_train, y_train)
ridge_pred_val = ridge.predict(X_val)
ridge_pred_test = ridge.predict(X_test)
print("=== Ridge Regression (Baseline) ===")
print(f"Validation RMSE: {np.sqrt(mean_squared_error(y_val, ridge_pred_val)):.2f}")
print(f"Validation MAE: {mean_absolute_error(y_val, ridge_pred_val):.2f}")
print(f"Test RMSE: {np.sqrt(mean_squared_error(y_test_capped, ridge_pred_test)):.2f}")
print(f"Test MAE: {mean_absolute_error(y_test_capped, ridge_pred_test):.2f}")
# Cell 11: Train XGBoost
xgb_model = XGBRegressor(
n_estimators=100,
max_depth=5,
learning_rate=0.1,
objective='reg:squarederror',
random_state=42
)
xgb_model.fit(X_train, y_train)
xgb_pred_val = xgb_model.predict(X_val)
xgb_pred_test = xgb_model.predict(X_test)
print("\n=== XGBoost Regressor ===")
print(f"Validation RMSE: {np.sqrt(mean_squared_error(y_val, xgb_pred_val)):.2f}")
print(f"Validation MAE: {mean_absolute_error(y_val, xgb_pred_val):.2f}")
print(f"Test RMSE: {np.sqrt(mean_squared_error(y_test_capped, xgb_pred_test)):.2f}")
print(f"Test MAE: {mean_absolute_error(y_test_capped, xgb_pred_test):.2f}")
# Cell 12: Maintenance Alarm Evaluation
ALARM_THRESHOLD = 30
y_test_alarm = (y_test_capped < ALARM_THRESHOLD).astype(int)
xgb_alarm = (xgb_pred_test < ALARM_THRESHOLD).astype(int)
print("\n=== Maintenance Alarm (Threshold = 30 cycles) ===")
print(f"Precision: {precision_score(y_test_alarm, xgb_alarm):.3f}")
print(f"Recall: {recall_score(y_test_alarm, xgb_alarm):.3f}")
print(f"F1-Score: {f1_score(y_test_alarm, xgb_alarm):.3f}")
# Cell 13: Save Model
joblib.dump(xgb_model, 'models/xgb_rul_model.joblib')
joblib.dump(feature_columns, 'models/feature_columns.joblib')
print("Model and artifacts saved to models/")
Your trained model is saved and evaluated. Next up, you will deploy it as a live REST API so maintenance teams can send sensor readings and get RUL predictions in real time.
Deploy the Prediction API
Your trained XGBoost model sits as a .joblib file on disk. A maintenance engineer cannot open a Jupyter Notebook to get a prediction.
In this step, you will wrap the model in a FastAPI web server that accepts sensor readings over HTTP and returns a predicted RUL with a maintenance alert flag. This turns your notebook experiment into a service that any monitoring system can call.
In this step, get ready to:
- Create a FastAPI application that loads your trained model at startup.
- Build a /predict endpoint that returns RUL estimates and maintenance alerts.
- Test the running API with Swagger UI.
Create the FastAPI application
The api/ directory already exists in your project (created in Cell 1 of your notebook). You will create a single Python file inside it that loads your model artifacts and defines the API schema.
- Create a new file called main.py inside the api/ folder.
- Add the following code to set up the app, load the model artifacts, and define the request/response schemas:
import joblib
import numpy as np
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="Turbofan RUL Prediction API", version="1.0.0")
model = joblib.load("models/xgb_rul_model.joblib")
scaler = joblib.load("models/scaler.joblib")
feature_columns = joblib.load("models/feature_columns.joblib")
ALARM_THRESHOLD = 30
class PredictionRequest(BaseModel):
sensor_readings: list[float]
class PredictionResponse(BaseModel):
predicted_rul: float
maintenance_alert: bool
unit: str = "cycles"
@app.get("/health")
def health_check():
return {"status": "healthy", "model": "xgb_rul_model"}
What does this code do?
The app loads all three model artifacts (the XGBoost model, the scaler, and the feature column names) once at startup. This avoids reading from disk on every request.
PredictionRequest defines the input shape: a flat list of floats representing sensor readings. PredictionResponse defines the output: the predicted RUL in cycles plus a boolean maintenance alert.
The /health endpoint is a simple liveness check that confirms the server is up and which model is loaded.
- Save api/main.py.
- You won't see a change yet. The next section adds the prediction logic.
Implement the /predict endpoint
The prediction endpoint accepts a POST request with sensor data, validates the feature count, runs inference through the XGBoost model, and returns the result with a maintenance alert flag.
- In api/main.py, add the following code below the health_check function:
@app.post("/predict", response_model=PredictionResponse)
def predict_rul(request: PredictionRequest):
input_array = np.array(request.sensor_readings).reshape(1, -1)
if input_array.shape[1] != len(feature_columns):
from fastapi import HTTPException
raise HTTPException(
status_code=400,
detail=f"Expected {len(feature_columns)} features, got {input_array.shape[1]}"
)
predicted_rul = float(model.predict(input_array)[0])
predicted_rul = max(0, predicted_rul)
return PredictionResponse(
predicted_rul=round(predicted_rul, 1),
maintenance_alert=predicted_rul < ALARM_THRESHOLD
)
What does this code do?
The endpoint reshapes the incoming list into a NumPy array and checks that the number of features matches what the model was trained on. If not, it returns a 400 error with a helpful message.
After prediction, the RUL is clipped to zero (a negative RUL makes no physical sense). The maintenance_alert flag fires when the predicted RUL drops below 30 cycles, matching the alarm threshold from your evaluation step.
- Save api/main.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved api/main.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete api/main.py file:
import joblib
import numpy as np
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="Turbofan RUL Prediction API", version="1.0.0")
model = joblib.load("models/xgb_rul_model.joblib")
scaler = joblib.load("models/scaler.joblib")
feature_columns = joblib.load("models/feature_columns.joblib")
ALARM_THRESHOLD = 30
class PredictionRequest(BaseModel):
sensor_readings: list[float]
class PredictionResponse(BaseModel):
predicted_rul: float
maintenance_alert: bool
unit: str = "cycles"
@app.get("/health")
def health_check():
return {"status": "healthy", "model": "xgb_rul_model"}
@app.post("/predict", response_model=PredictionResponse)
def predict_rul(request: PredictionRequest):
input_array = np.array(request.sensor_readings).reshape(1, -1)
if input_array.shape[1] != len(feature_columns):
from fastapi import HTTPException
raise HTTPException(
status_code=400,
detail=f"Expected {len(feature_columns)} features, got {input_array.shape[1]}"
)
predicted_rul = float(model.predict(input_array)[0])
predicted_rul = max(0, predicted_rul)
return PredictionResponse(
predicted_rul=round(predicted_rul, 1),
maintenance_alert=predicted_rul < ALARM_THRESHOLD
)
Test the API
With the file complete, you can start the server and interact with it through the auto-generated Swagger UI documentation.
- Open a terminal in your project root directory (the folder that contains the api/ and models/ folders).
- Start the FastAPI server by running:
uvicorn api.main:app --reload
What does this command do?
uvicorn is a lightweight web server for Python. api.main:app tells it to look inside the api/ folder, open main.py, and find the app object. The --reload flag auto-restarts the server when you save changes.
- You should see output indicating the server is running at http://127.0.0.1:8000.
- Open your browser and navigate to http://127.0.0.1:8000/docs.
You should see the Swagger UI page titled "Turbofan RUL Prediction API" with your two endpoints listed: /health and /predict.
- Click the POST /predict endpoint to expand it.
- Click Try it out.
- In the request body field, replace the default content with a test payload containing the correct number of features (matching your feature_columns list length). You can use zeros as placeholder values:
{
"sensor_readings": [0.5, 0.3, 0.7, 0.2, 0.5, 0.4, 0.6, 0.3, 0.5, 0.4, 0.5, 0.3, 0.6, 0.4, 0.5, 0.3, 0.7, 0.5, 0.4, 0.3, 0.5, 0.6, 0.4, 0.5, 0.3, 0.7, 0.2, 0.5, 0.4, 0.6, 0.3, 0.5, 0.4, 0.5, 0.3, 0.6, 0.4, 0.5, 0.3, 0.7, 0.5, 0.4, 0.3, 0.5, 0.6, 0.4, 0.5, 0.3, 0.7, 0.2, 0.5, 0.8]
}
Finding your feature count
If you get a 400 error saying how many features are expected, adjust your sensor_readings list to match that exact length. The error message tells you the required count.
- Click Execute.
- In the response body, you should see a JSON object with predicted_rul (a number), maintenance_alert (true or false), and unit set to cycles.
Server won't start?
Make sure your terminal is in the project root folder (the one containing api/ and models/). If you are inside the api/ folder, the model file paths won't resolve correctly.
If you see a ModuleNotFoundError, confirm you installed FastAPI and uvicorn in Step 1. Re-run the install command if needed.
help me debug why my FastAPI server won't start
Your predictive maintenance model is now live as a local REST API. Any monitoring system, dashboard, or alerting tool can send sensor readings to this endpoint and get actionable maintenance decisions in milliseconds.
Secret mission
Add SHAP Explainability
Your model predicts RUL, but can you explain WHY it made that prediction? In this mission, you will use SHAP to identify which sensors are the strongest failure predictors and build an explainability endpoint that shows which features drove each individual prediction.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Your project folder containing rul_prediction.ipynb, the data/, models/, and api/ directories.
- The running uvicorn server (FastAPI).
- Installed Python packages (xgboost, shap, fastapi, uvicorn, scikit-learn, pandas, numpy, matplotlib).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the model and API.
- Your project files, trained model, and installed packages remain on your machine.
- The uvicorn server stays running in your terminal until you close it.
- You can restart the API anytime by running uvicorn api.main:app --reload from your project folder.
Pause - I'll come back to this later
Shut down the running server to free up your terminal. Your project files and packages stay intact for when you return.
- Switch to the terminal where uvicorn is running.
- Press Ctrl+C to stop the server.
- Your project files, trained model artifacts, and installed packages remain on your machine.
Delete - I don't want to use this again
Remove all project resources and uninstall the packages to start fresh.
- Switch to the terminal where uvicorn is running and press Ctrl+C to stop the server.
- Delete your project folder (the folder containing rul_prediction.ipynb) by running this command from its parent directory:
macOS
rm -rf ~/Desktop/turbofan-rul
Windows
rmdir /s /q %USERPROFILE%\Desktop\turbofan-rul
- Uninstall the Python packages you installed for this project by running:
pip uninstall -y xgboost shap fastapi uvicorn
What about scikit-learn, pandas, numpy, and matplotlib?
These are common data science packages you likely use across multiple projects. Only uninstall them if you are sure no other project depends on them.
Nice Work!
Nice Work!
You just built a production-style predictive maintenance system that takes raw turbofan sensor data, predicts remaining engine life, and serves predictions through a REST API.
You've learned how to:
- Build a complete ML pipeline from raw NASA C-MAPSS sensor data to RUL predictions, engineering 50+ degradation-capturing features from 14 active sensors.
- Train and compare Ridge vs XGBoost models, then reframe regression output as a maintenance alarm evaluated with precision, recall, and F1-score.
- Deploy the trained model as a FastAPI REST API with a /predict endpoint and auto-generated Swagger documentation.
- Secret Mission: Used SHAP to identify which sensors are the strongest failure predictors, generated a beeswarm plot, and built a /explain endpoint that returns per-prediction feature contributions.
Ready to quiz yourself?