A complete MLOps pipeline for pneumonia detection from chest X-ray images with a stacked ensemble (3 base models + logistic regression meta-learner with clinical features), from training to production deployment via REST API and Docker.
This project implements a binary lung-opacity classifier using a stacked ensemble trained on the RSNA Pneumonia Detection Challenge dataset (stage2_train_metadata.csv). The ensemble combines three diverse base models and a meta-learner that also consumes clinical metadata (age, sex, imaging position).
| Stage | Phase | Description |
|---|---|---|
| 1 | EDA | Explore raw data, understand structure, identify issues |
| 2 | Preprocessing | Clean & transform images (resize, normalize, augment) |
| 3 | Modelling | Train & optimise ResNet18 + TinyCNN ensemble with stacking |
| 4 | Code structure | Modular Python package (python/pcxp_mlops/) |
| 5 | Tests | Unit & functional tests for pipeline components |
| 6 | DVC | Data versioning with DVC pipeline |
| 7 | MLflow | Experiment tracking – hyperparams, metrics, artefacts |
| 8 | API | REST service exposing single-model & ensemble endpoints |
| 9 | Docker | Containerised deployment |
| 10 | GitHub | Version control, structured repo, documentation |
The API serves an interactive web interface at / where you can upload a chest X-ray, adjust the confidence threshold, and view the prediction alongside model version and request metadata.
.
├── python/
│ ├── api.py # ASGI entrypoint (single & ensemble)
│ ├── train.py # Single-model training CLI
│ ├── evaluate.py # Single-model evaluation CLI
│ ├── train_ensemble.py # Ensemble training CLI
│ ├── evaluate_ensemble.py # Ensemble evaluation CLI
│ ├── ml_pipeline.py # Legacy compatibility helpers
│ └── pcxp_mlops/
│ ├── api.py # FastAPI app factories & endpoints
│ ├── config.py # Centralised paths & constants
│ ├── data_loader.py # RSNA CSV dataset + transforms
│ ├── evaluation.py # Evaluation & MLflow logging
│ ├── metadata.py # Model metadata persistence
│ ├── metrics.py # Metric helpers
│ ├── model.py # ResNet18, TinyCNN, wrappers, ensemble
│ ├── predict.py # Inference services
│ ├── training.py # Training loops (single + ensemble)
│ └── static/ # Web UI (HTML, CSS, JS)
├── models/
│ ├── metadata.json # Single-model version & metrics
│ ├── classes.json # Class label order
│ ├── model.pth # Single ResNet18 weights
│ └── ensemble/ # Ensemble artefacts
│ ├── metadata.json
│ ├── meta_model.pkl
│ ├── resnet18.pth
│ ├── tiny_cnn.pth
│ └── resnet18_low_lr.pth
├── tests/
│ ├── test_api.py
│ └── test_ml_pipeline.py
├── data/
│ ├── stage2_train_metadata.csv # RSNA metadata (DVC-tracked)
│ ├── stage2_test_metadata.csv # Test metadata (DVC-tracked)
│ └── Training/Images/ # PNG images (DVC-tracked)
├── Dockerfile
├── docker-compose.yml
├── dvc.yaml # DVC pipeline definition
├── requirements.txt
└── roadmap.txt
The pipeline uses the RSNA Pneumonia Detection Challenge dataset in CSV-driven format:
data/stage2_train_metadata.csv– 30,227 rows, 26,684 unique patients; containspatientId,Target(0/1),age,sex,positioncolumns.data/Training/Images/{patientId}.png– 1024×1024 grayscale chest X-rays.
Images are referred to by patientId from the CSV; the old folder-based layout (NORMAL/, PNEUMONIA/ subdirectories) is no longer used.
Training uses an 80/20 random split from the training CSV (the test CSV lacks a Target column).
- ResNet18 (pretrained on ImageNet) – entrypoints
python.train,python.evaluate.
Three diverse base models retrained on a held-out 80% split and combined via logistic regression:
| Model | Architecture | LR | Epochs | Purpose |
|---|---|---|---|---|
resnet18 |
ResNet18 (pretrained) | 1e-3 | 5 | High-capacity baseline |
tiny_cnn |
TinyCNN (scratch) | 1e-3 | 8 | Lightweight diversity |
resnet18_low_lr |
ResNet18 (pretrained) | 1e-4 | 5 | Regularised variant |
Meta-learner input (6 features): [prob_resnet18, prob_tiny_cnn, prob_resnet18_low_lr, age_norm, sex_enc, pos_enc]
age_norm= age / 100sex_enc= 0 (male), 1 (female)pos_enc= 0 (AP), 1 (PA)
pip install -r requirements.txtpython -m python.trainPress Ctrl+C at any point during training to save the current weights and stop early.
python -m python.evaluatepython -m python.train_ensembleThis runs two phases:
- Phase 1 – trains all 3 base models on the full dataset (produces
models/ensemble/{name}.pth) - Phase 2 – retrains each base for
max(epochs//2, 2)epochs on an 80% inner fold, generates validation predictions, fitsLogisticRegressionon[probs, age, sex, position](producesmodels/ensemble/meta_model.pkl)
Press Ctrl+C during any training loop to save partial progress and stop.
python -m python.evaluate_ensembleuvicorn python.api:app --host 0.0.0.0 --port 8000uvicorn python.api:ensemble_app --host 0.0.0.0 --port 8000python -m unittest discover -s tests| Method | Path | Description |
|---|---|---|
| GET | / |
Web interface |
| GET | /health |
Status & model version |
| GET | /model-info |
Full metadata & metrics |
| POST | /predict |
Classify a chest X-ray |
POST /predict request:
{
"image_path": "data/PCXP/test/PNEUMONIA/person100_bacteria_475.jpeg"
}Alternative: image_base64 (base64 string), optional threshold (0.0–1.0).
Response:
{
"request_id": "7f0d5d1c-8c8d-44c4-8687-7e4f0d7d6f13",
"predicted_class": "PNEUMONIA",
"predicted_index": 1,
"probability": 0.97,
"threshold": 0.87,
"model_version": "pcxp-resnet18-v1"
}Same endpoints plus optional clinical metadata on POST /predict:
{
"image_path": "...",
"age": 55,
"sex": "M",
"position": "AP"
}age, sex, and position are forwarded to the meta-learner for a prediction that incorporates patient context.
All training functions wrap the epoch loop in try/except KeyboardInterrupt. Press Ctrl+C at any time:
^C
resnet18 | Interrupted — saving partial progress at epoch 3/5.
resnet18 | Total: 142.3s (47.4s/epoch)
The model weights from the last completed epoch are saved to disk before the process exits normally.
The dataset is versioned with DVC. The pipeline (dvc.yaml) defines four stages:
| Stage | Produces | Description |
|---|---|---|
train |
model.pth, classes.json, metadata.json |
Single ResNet18 |
evaluate |
metadata.json (updated) |
Metrics on held-out split |
train_ensemble |
ensemble/*.pth, ensemble/meta_model.pkl, ensemble/metadata.json |
Full ensemble |
evaluate_ensemble |
ensemble/metadata.json (updated) |
Ensemble metrics |
Reproduce the full pipeline:
dvc reproExperiments are logged during evaluation. Launch the UI to compare runs:
mlflow uiBoth single-model and ensemble evaluations create separate experiments (Pneumonia_Model_Evaluation and Pneumonia_Ensemble_Evaluation).
docker build -t pcxp-api .docker run --rm -p 8000:8000 -v "$(pwd)/models:/app/models" pcxp-apidocker compose up --buildThe Compose setup mounts ./models as a volume, exposes port 8000, and sets the required environment variables.
- PyTorch & Torchvision – deep learning framework
- FastAPI & Uvicorn – REST API
- DVC – data versioning
- MLflow – experiment tracking
- Docker & Docker Compose – containerisation
- scikit-learn – logistic regression meta-learner, metrics
- joblib – meta-model serialisation
- pandas – CSV metadata parsing
- unittest – automated testing
