π Multiclass flight-class prediction with a PyTorch MLP built from scratch - benchmarked against Random Forest on 129,880 passenger records, with a leakage-free 70/15/15 protocol
A multiclass classification project predicting Class (Business / Eco / Eco Plus) on the Airline Passenger Satisfaction dataset from Kaggle. The main model is a multilayer perceptron implemented from scratch in PyTorch; a scikit-learn Random Forest serves as the reference model.
The project is as much about method as about scores. Preprocessing is fitted on the training split alone, hyperparameters are selected on a dedicated validation split via an 18-configuration grid search, and the test split is touched exactly once at the end. The imbalanced minority class (Eco Plus, ~7%) is addressed with class weights in the loss rather than resampling β which is precisely why the MLP loses on accuracy and wins on F1-macro.
- π§ MLP from scratch in PyTorch β
Linear β BatchNorm1d β ReLU β Dropoutblocks, AdamW,CrossEntropyLosswith class weights,ReduceLROnPlateauand early stopping on validation F1-macro. - π 18-configuration grid search β 3 architectures Γ 3 dropout rates Γ 2 learning rates, all selected on the validation split only.
- π² Random Forest reference β a scikit-learn baseline evaluated under exactly the same split and metrics.
- π No data leakage by construction β imputers,
OneHotEncoderandStandardScalerarefiton train only, thentransformed onto val and test. - βοΈ Minority-class handling without resampling β class weights in the loss instead of SMOTE/oversampling, per the project requirement.
- π§Ή Domain-aware cleaning β implausible ages, distances, delays and service ratings are converted to NaN before median/mode imputation.
- β‘ Automatic device selection β CUDA, Apple MPS or CPU, detected at runtime.
- π Eight generated figures β EDA, training dynamics, LR schedule, grid search and side-by-side confusion matrices.
- π₯οΈ Streamlit dashboard β five tabs, including a button that runs the whole experiment from the UI.
All figures below are regenerated by python3 run_experiment.py into results/airline/plots/.
| Class distribution | Missing data |
|---|---|
![]() |
![]() |
| Feature histograms | Features vs. class |
|---|---|
![]() |
![]() |
| Grid search comparison | Loss and F1 history |
|---|---|
![]() |
![]() |
| Learning-rate schedule | Confusion matrices |
|---|---|
![]() |
![]() |
Source: results/airline/model_comparison.csv.
| Model | Accuracy | Precision macro | Recall macro | F1-macro |
|---|---|---|---|---|
| MLP baseline | 0.7633 | 0.6462 | 0.6666 | 0.6390 |
| MLP tuned | 0.7750 | 0.6497 | 0.6705 | 0.6465 |
| Random Forest | 0.8644 | 0.6791 | 0.6248 | 0.6058 |
Best configuration (run_metadata.json, selected on validation F1-macro = 0.6536): MLP256x128x64_d0.30_lr1e-03 β hidden sizes 256Γ128Γ64, dropout 0.3, learning rate 1e-3.
Below are the detailed per-class reports for the 3 models, from results/airline/classification_report_*.txt.
precision recall f1-score support
Business 0.94 0.85 0.89 9325
Eco 0.81 0.73 0.77 8747
Eco Plus 0.18 0.42 0.26 1411
accuracy 0.76 19483
macro avg 0.65 0.67 0.64 19483
weighted avg 0.83 0.76 0.79 19483
precision recall f1-score support
Business 0.94 0.85 0.89 9325
Eco 0.81 0.75 0.78 8747
Eco Plus 0.19 0.41 0.26 1411
accuracy 0.77 19483
macro avg 0.65 0.67 0.65 19483
weighted avg 0.83 0.77 0.80 19483
precision recall f1-score support
Business 0.94 0.91 0.93 9325
Eco 0.80 0.95 0.87 8747
Eco Plus 0.30 0.01 0.02 1411
accuracy 0.86 19483
macro avg 0.68 0.62 0.61 19483
weighted avg 0.83 0.86 0.84 19483
Business: all models work very well.Eco: Random Forest has very highrecall(0.95), but at the expense of theEco Plusclass.Eco Plus: the MLP (recall ~0.41-0.42) detects this class much better than RF (recall 0.01).- That is why RF has higher
accuracybut a worseF1-macrothan the MLP tuned.
- MLP tuned wins on F1-macro β it balances the classes better thanks to class weights.
- RF wins on accuracy, but almost ignores Eco Plus (recall ~1%).
- Eco Plus is a problematic class (~7%) β the MLP reaches recall ~41%.
- Class weights in the loss are key to detecting the minority class.
- Limitations: the small Eco Plus class, no feature engineering, a grid search of 18 configurations.
| Item | Value |
|---|---|
| Source | Kaggle β Airline Passenger Satisfaction |
| Local files | data/train.csv, data/test.csv |
data/train.csv (Kaggle) |
103,904 records |
data/test.csv (Kaggle) |
25,976 records |
| Total (used in the project) | 129,880 records |
| After the 70/15/15 split | train 90,915 / val 19,482 / test 19,483 |
| Target | Class (Business ~48%, Eco ~45%, Eco Plus ~7%) |
Note: "~105k" usually refers to the
train.csvfile alone (~104k), while "~130k" is train + test combined. These are not contradictory numbers β they are different stages (Kaggle files vs the split in the experiment).
Unnamed: 0β an artificial CSV indexidβ a passenger identifiersatisfactionβ the original binary target (we do not use it)
- Numeric: Age, Flight Distance, 14 service ratings (scale 0β5), Departure Delay, Arrival Delay
- Categorical: Gender, Customer Type, Type of Travel
Together with one-hot expansion this yields 24 input features to the model.
- Removing unrealistic values (
remove_unrealistic_values):- Age < 0 or > 100 β NaN
- Flight Distance β€ 0 β NaN
- Delays < 0 or > 1440 min β NaN
- Service ratings < 0 or > 5 β NaN
- Imputing missing values:
- Median (numeric), mode (categorical)
- Fit on train only β no data leakage
- OneHotEncoder β categorical β binary columns
- StandardScaler β numeric β z-score (fit on train)
- Split: 70% train / 15% val / 15% test, stratified by target
Val is not a "final test" but a control during learning.
All data (after combining train.csv + test.csv from Kaggle)
β
βββ 70% TRAIN β the model LEARNS (updates weights)
βββ 15% VAL β control DURING training (does not update weights)
βββ 15% TEST β the FINAL exam (only at the very end, once)
Order in the project:
- Preprocessing β parameters (mean, median, one-hot) computed on train only, then the same applied to val and test.
- Grid search (18 MLP variants) β each variant:
- learns on train (70%),
- is checked every epoch on val (15%),
- we pick the best variant by F1-macro on val.
- Training baseline and tuned β again train + val (early stopping, LR scheduler).
- Only at the end β evaluating baseline, tuned and Random Forest on test (15%) β reports, the table above, charts.
Val (15%) is used for:
- early stopping (when to stop training),
- reducing the learning rate (the
scheduler_lr.pngchart), - selecting the best configuration from the grid search.
Test (15%) β the model was not used on it when choosing hyperparameters. It is a fair final result.
Important: val does not replace the test. Val = many times during training. Test = once at the end.
Data leakage = the model or preprocessing gets a hint from the test set or from the answers before we do the final evaluation. Then the test result is too high and not credible.
Analogy: you saw the exam questions in advance β the grade does not reflect real knowledge.
Suppose the mean age on train = 40 and on test = 60.
Wrong (leakage):
- You compute the mean from train + test together β e.g. 45.
- You scale all rows with that mean.
- The model "knows" indirectly that the test contains older passengers (because the mean of 45 includes the test).
Right (no leakage β as we do it):
fiton train: mean = 40, std from train.transformon test: age 60 β(60 - 40) / std_trainβ the test does not change the mean, it only uses the parameters from train.
The same applies to: the median for missing values, OneHot (which categories exist), the grid search (which model to pick).
| Mistake | What you do wrong | Consequence |
|---|---|---|
| Preprocessing on all data | fit on train+test+val together |
The test "enters" the mean, median, one-hot |
| Training on the test | Weights learn on the 15% test | The test is no longer unseen |
| Selecting a model on the test | Grid search: "best" = highest score on test | The test is used for tuning |
satisfaction in the features |
The model sees satisfaction when predicting Class |
A hint (cheating) |
Class in feature preprocessing |
E.g. scaling using the label | The direct answer in the features |
1. Combine train.csv + test.csv from Kaggle β 129,880 rows
2. Split 70% / 15% / 15% β train / val / test
3. Fit preprocessing ONLY on train β median, scaler, one-hot
4. Transform on val and test β the same parameters, no fit
5. Train the MLP on train β weights from train
6. Control on val β early stopping, LR, grid search
7. ONE evaluation on the test β reports (0.76, 0.77, 19483 samples)
| Step | Set | Can it leak? | Here |
|---|---|---|---|
| Mean age, median of delays | train only at fit |
Yes, if you compute it with the test | No β train only |
| Scaling val/test | transform | Yes, if you fit again |
No β transform only |
| Which MLP to pick (grid) | val | Yes, if done on test | Val |
| Accuracy / F1 report | test | Yes, if you tuned on test first | Test only at the end |
The satisfaction column |
β | Yes, as a feature | Removed |
The Class target |
only as y | Yes, in the features X | Only y, not in X |
| Word | In plain terms | When |
|---|---|---|
| fit | "learn the parameters" | On train (70%) only β e.g. the median of Age, the mean for the scaler |
| transform | "apply those parameters" | On val and test β without recomputing |
Leakage would be: fit on the whole dataset or on train+test.
Here: fit_preprocessor(train_df) in preprocessing.py, then transformuj_cechy(val/test, ...).
preprocessing.py
fit_preprocessor(train_df) β fit ONLY on train
transformuj_cechy(val_df) β transform
transformuj_cechy(test_df) β transform
experiment.py
trenuj_mlp(..., x_train, y_train, x_val, y_val) β training + val
przewidz_mlp(..., x_test) β test at the end
For the defense (one sentence): "Leakage is when the test or the answer influences training. Here we fit preprocessing on train, tune the model on val, and use the test only once for the final report."
Hidden-layer architecture
Linear β BatchNorm1d β ReLU β Dropout
- Linear β a linear transformation (learned weights + bias)
- BatchNorm1d β batch normalization (training stabilization)
- ReLU β a non-linear activation max(0, x)
- Dropout β regularization (random neuron zeroing)
The output layer has 3 neurons (output_dim=3), one per class of the Class target.
| Component | Description |
|---|---|
| Optimizer | AdamW (weight_decay=1e-4) |
| Loss | CrossEntropyLoss with class weights |
| Scheduler | ReduceLROnPlateau (factor=0.5, patience=3) |
| Early stopping | Validation macro F1 (patience=5, min_delta=1e-4) |
| Batch size | 1024 |
| GPU | Automatic CUDA / MPS / CPU detection |
| Parameter | Values |
|---|---|
| Architecture | (128,64), (256,128,64), (512,256,128) |
| Dropout | 0.1, 0.2, 0.3 |
| Learning rate | 1e-3, 5e-4 |
Baseline: baseline_MLP128_64 β (128,64), dropout=0.2, lr=1e-3, max_epochs=35, patience=6.
Tuned: the best from the grid β MLP256x128x64_d0.30_lr1e-03, max_epochs=30, patience=5.
Full per-configuration results are in results/airline/grid_search_results.csv.
- PyTorch (
>=2.0,<3) β the MLP, AdamW,CrossEntropyLosswith class weights,ReduceLROnPlateau, CUDA/MPS/CPU device selection
- scikit-learn (
>=1.3,<2) βRandomForestClassifierreference model,OneHotEncoder,StandardScaler, stratified splitting and the metrics suite - NumPy (
>=1.24,<3) β numeric arrays
- pandas (
>=2.0,<3) β loading, cleaning, results tables - Matplotlib (
>=3.7,<4) and seaborn (>=0.13,<1) β all eight generated figures
- Streamlit (
>=1.28,<2) β the five-tab dashboard - Jupyter / ipykernel β the accompanying notebook in
docs/ - python-docx (
>=1.1,<2) β documentation generation
-
Docker β for the containerised path below (recommended)
-
Python 3.10+
-
Both Kaggle CSVs present at
data/train.csvanddata/test.csv
The container installs the scientific stack, runs the experiment and serves the dashboard in one step β no local Python setup and no virtual environment:
docker compose -f .tools/docker/docker-compose.yml up --buildThe dashboard is then available at http://localhost:8501.
Generated artefacts (results/, data/) are bind-mounted back to the host, so
charts and metrics written inside the container survive it being removed. Stop
the stack with:
docker compose -f .tools/docker/docker-compose.yml downgit clone https://github.com/dawidolko/Airline-Passenger-Classifier-Python-MLP-Classifier-Python.git
cd Airline-Passenger-Classifier-Python-MLP-Classifier-Pythonpip install -r requirements.txt# experiment only (training + evaluation + charts):
python3 run_experiment.py
# Streamlit dashboard:
streamlit run streamlit_app.pystart.sh (macOS/Linux) and start.bat (Windows) create or repair .venv, install the requirements, run python run_experiment.py, then launch Streamlit at http://localhost:8501.
chmod +x start.sh
./start.shstart.bat| Tab | Content |
|---|---|
| Summary | Best model, F1-macro, accuracy and the compute device used |
| Reports | Per-class classification reports for all three models |
| Charts | The eight generated figures |
| Conclusions | Interpretation of the comparison |
| Files | Generated artifacts under results/airline/ |
The sidebar "Run the full experiment" button launches training directly from the UI.
Airline-Passenger-Classifier-Python-MLP-Classifier-Python/
βββ π§ airline_project/
β βββ __init__.py
β βββ config.py # Paths, hyperparameters, feature lists, seed (42)
β βββ model.py # The AirlineMLP class + device selection
β βββ preprocessing.py # Cleaning, splitting, imputation, scaling
β βββ experiment.py # Grid search, training, evaluation, charts
βββ π data/
β βββ train.csv # Kaggle train file (103,904 records)
β βββ test.csv # Kaggle test file (25,976 records)
βββ π results/airline/
β βββ model_comparison.csv
β βββ grid_search_results.csv
β βββ run_metadata.json
β βββ classification_report_mlp_baseline.txt
β βββ classification_report_mlp_tuned.txt
β βββ classification_report_random_forest.txt
β βββ plots/
β βββ class_distribution.png
β βββ missing_data.png
β βββ feature_histograms.png
β βββ features_vs_class.png
β βββ grid_search_comparison.png
β βββ loss_and_f1_history.png
β βββ scheduler_lr.png
β βββ confusion_matrices_side_by_side.png
βββ π docs/
β βββ diagrams/pipeline.svg
β βββ airline_passenger_satisfaction_mlp_project2.ipynb
β βββ dokumentacja_do125148.docx
βββ βΆοΈ run_experiment.py # CLI entry point
βββ π₯οΈ streamlit_app.py # Streamlit dashboard
βββ π start.sh / start.bat # Experiment + dashboard
βββ π¦ requirements.txt
βββ π README.md
| What you see | Number | What it means |
|---|---|---|
data/train.csv |
103,904 | A separate Kaggle file (~104β105k) β not the same as "train 70%" in the model |
data/test.csv |
25,976 | The second Kaggle file |
| All data in the project | 129,880 | train.csv + test.csv combined, then one 70/15/15 split |
| Train in the model (70%) | 90,915 | This is where the model learns |
| Val (15%) | 19,482 | Control during training |
| Final test (15%) | 19,483 | The final exam β hence 19483 in the reports |
There is no error: ~105k is the Kaggle train file, ~130k is the sum of both files, ~91k is the train inside the experiment, ~19.5k is the final test.
If in the documentation you see the classes satisfied and neutral or dissatisfied instead of Business / Eco / Eco Plus, that is an old chart/screenshot (the original Kaggle target satisfaction), not a result of this project.
In this project the reports are for Class β see results/airline/classification_report_*.txt or the classification reports above.
Unnamed: 0is a technical row number from the CSV, not a feature.idis a passenger identifier; the model should not learn from numbers.satisfactionis a different target (satisfied/dissatisfied). In this project the target isClass.
The CSV has two separate columns:
| Column | Meaning |
|---|---|
satisfaction |
Whether the passenger is satisfied: satisfied or neutral or dissatisfied |
Class |
The flight class: Business, Eco, Eco Plus |
These are not the same (you can be satisfied in Eco or dissatisfied in Business).
- We drop the
satisfactioncolumn from the input features β the model does not see it. - We keep
Classas the answer (target) to learn. - Rows with
satisfiedandneutral or dissatisfiedstay β we use their other columns. Classdoes not replacesatisfactionβ from the start we predict the travel class, not satisfaction.
3 text columns: Gender, Customer Type, Type of Travel.
OneHotEncoder β each value gets its own 0/1 column, e.g. Gender_Male=1, Gender_Female=0.
| Source | Values | Columns after encoding |
|---|---|---|
| Gender | 2 | 2 |
| Customer Type | 2 | 2 |
| Type of Travel | 2 | 2 |
| Total | 3 | 6 |
Together with the 18 numeric features β 24 input features to the model.
Not into categories. The 18 columns stay numeric: cleaning β imputing missing values (median) β StandardScaler (scaling). One-hot applies only to the categorical features.
It is the same value, only rounded differently:
model_comparison.csvβ the full number (e.g. 0.7633),classification_report_*.txtβ rounded to 2 decimals (0.76).
There is no contradiction β the report is just visually "flatter".
- 19483 = the number of all samples in the test (total support),
- accuracy (e.g. 0.76) = a single metric: the % of all correct predictions across the 3 classes combined.
There are separate rows per class (Business, Eco, Eco Plus). This is 3-class classification, not binary (2 values).
- LR (learning rate) = the step size when updating the weights.
- At the start the LR is larger, to train the model faster.
- When F1 on val stops growing β
ReduceLROnPlateaulowers the LR (usually by half). - On the chart you can see "steps" going down β this is normal and desirable.
- The idea: a large step at the start, a smaller step at the end = more stable model "fine-tuning".
- fit = compute the preprocessing parameters (mean, std, median, category dictionary) on train.
- transform = apply the same parameters to val/test.
- This way there is no data leakage (the model does not peek at the test).
If a category not present in train appears in val/test, the code does not crash.
- 80/20 is fine if you only train and test.
- Here we tune the model (grid search + early stopping), so a separate validation set is needed.
- Hence: 70% train, 15% validation, 15% test.
Because we combine data/train.csv + data/test.csv from Kaggle and then make our own 70/15/15 split. The final test is 15% of the whole, i.e. about 19,483.
We have 3 classes of the Class target: Business, Eco, Eco Plus. That is why the last layer has 3 neurons.
Linear: computes weighted sums of features.BatchNorm: stabilizes the values between layers.ReLU: adds non-linearity (the model becomes "smarter" than a plain line).Dropout: randomly disables some neurons during training so the model does not overfit.
A larger network can learn harder relationships, but it overfits more easily. That is why we test several variants and pick the best one.
Automatically checking many parameter combinations (architecture, dropout, learning rate) and choosing the best one by the validation result.
A loss function for multiclass classification. It penalizes the model when it scores the true class low.
The rare class (Eco Plus) receives a larger penalty for a mistake, so the model focuses on it more.
F1 computed separately for each class, then averaged. Each class has the same weight.
- Oversampling: increasing the number of samples of the minority class.
- SMOTE: creating new, synthetic samples of that class.
In this project we do not use this (per the requirement) β we use class weights.
accuracy: the percentage of all correct predictions.precision_macro: how often the model is right when it points to a class.recall_macro: how many real cases of a class it detected.f1_macro: a precision/recall compromise, averaged over classes.
The model's evaluation mode: dropout is disabled and batchnorm behaves stably.
- AdamW: a weight-update algorithm.
- ReduceLROnPlateau: reduces the learning rate when the result stalls.
- Early stopping: ends training when there is no improvement for several epochs.
- Epoch: one full pass through the entire training set.
This project is open source and available under the terms described in the LICENSE file.
Created by Dawid Olko
- Website β dawidolko.pl
- LinkedIn β @dawidolko







