Predict the final mAb product titer of a simulated fed-batch upstream bioprocess from a mix of scalar process settings and daily time-series process data.
- Raw data validation: Validate data quality
- Data preparation: Clean and preprocess data
- Exploratory data analysis (EDA): Investigate data distribution
- Data transformation: Feature engineering
- Baseline model training: Train baseline model
- Uncertainty quantification: Quantify model uncertainty with MAPIE
- Model versioning: Track metrics and version models, and log model performance
- Implementation: Design ML workflow architecture, adopt MLOps lifecycle, linting and format checking, and documentation
- Model deployment: Deploy and containerize model with Uvicorn/Docker and test inference requests
I use Notion with Kanban board to track tasks' status for this project. Link: https://app.notion.com/p/Predicting-mAb-Titer-Tasks-to-Done-220ea55998d882d1aec7019f8abeef3b
Useful materials for learning Titer prediction and bioprocess:
git clone <repository-url>
cd ml-titer
python -m venv .venv
source .venv/bin/activate
pip install uv
uv syncAn endpoint on the App server using FastAPI framework handles the prediction requests and returns the value predicted by the deployed ML pipeline. The endpoint is server/predict with a POST operation.
- Use Uvicorn Server
uv run uvicorn main:app --host 0.0.0.0 --port 8000- Use Docker to containerize this server
docker build -t ml-titer .
docker run --rm -p 8000:8000 ml-titerYou can visit http://localhost:8000/docs to see the API documentation.
curl -X GET http://0.0.0.0:8000/healthResponse
{
"status": "healthy"
}curl http://0.0.0.0:8000/modelsResponse
{
"active_model": "pls_model.joblib",
"available_models": [
{
"id": "mlr",
"algorithm": "Multiple Linear Regression (MLR)",
"file": "mlr_model.joblib"
},
{
"id": "pls",
"algorithm": "Partial Least Squares (PLS)",
"file": "pls_model.joblib"
},
{
"id": "xgb",
"algorithm": "XGBoost Regressor",
"file": "xgb_model.joblib"
}
]
}Create payload from YAML file
python spec_yml_to_json.py > payload.jsonExample payload.json :
{
"model": "pls", # Options: mlr, pls, xgb
"timestamps": [...],
"values": {...},
}Make inference request
curl -X POST http://0.0.0.0:8000/predict \
-H "Content-Type: application/json" \
--data @payload.jsonResponse
{
"status": "success",
"prediction": 2541.33,
"unit": "mg/L",
"uncertainty": 390.24,
"confidence_interval": {
"lower_bound": 2151.09,
"upper_bound": 2931.57
},
"model_info": {
"name": "PLSRegression",
"version": "1.0.0",
"file": "pls_model.joblib"
}
}├── data
│ ├── *.csv # Dataset files
├── Dockerfile # Docker file
├── inference_server_spec.yml # Example inference spec yaml
├── main.py # App microservice
├── ml
│ ├── data.py # Helper functions for data processing
│ ├── mlflow_utils.py # Helper functions for MLflow
│ ├── model.py # Helper functions for model training
│ └── train_model.py # Script to train models for inference request
├── models
│ ├── *.joblib # Pretrained models for inference request
├── notebook
│ ├── baseline.ipynb # Baseline model
│ ├── eda.ipynb # Data exploration, cleaning & feature engineering, visualizations
│ └── test_template.ipynb # Test model prediction
├── pyproject.toml # Project configuration
├── README.md # This file
├── spec_yml_to_json.py # Script to convert inference server yml to JSON file
├── tests
│ ├── test_*.py # Pytest tests
└── uv.lock # uv configuration
| File | Content |
|---|---|
datahow_interview_train_data.csv |
Training inputs, long format (one row per experiment × day), 100 experiments / 990 rows. |
datahow_interview_train_targets.csv |
Training targets, one row per experiment. |
datahow_interview_test_data.csv |
Test inputs (same schema), 20 experiments / 300 rows. |
datahow_interview_test_targets-TEMPLATE.csv |
Submission placeholder (dummy 2000 values). |
Z:scalar parametersW:control profilesX:measured observationsY:Titertarget (one scalar per experiment)
See eda.ipynb for exploratory data analysis (EDA)
- Very small sample size:
- 100 experiments for training (10 held out as the final test set for 10-fold CV).
- Mixed data types:
- static scalar process settings (
Z:*), time-varying control profiles (W:*), and time-varying measurements (X:*) of different, ragged lengths (7-14 days).
- static scalar process settings (
- Strong multicollinearity:
Z:*parameters are generated from a designed experiment (feed start/end, pH/temp start-end-shift are linked)- Time-series summary features (final/max/mean/AUC of the same variable) are highly correlated with each other.
- Single scalar target per experiment:
- Titer is measured only at the end, i.e., this is a batch-to-scalar regression problem, not a sequence-to-sequence forecasting problem.
- Underlying process is nonlinear
- E.g. microbial/cell growth kinetics, feed-limited dynamics, saturation effects
- From the PCA analysis, I found that VCD Peak Timing (
VCD_time_to_peak, r = +0.746). Extending exponential growth phase before peak VCD is the single strongest factor for high titer.
-
Experiment duration mismatch: Experiment runs for a different duration (7, 8, 9, or 14 days, set by
Z:ExpDuration, starting from day 0), while in the test set the experiment is recorded for 14 days. Fortunately, the goal of this task is to predict only the titer at the final/day-14 timepoint, with full days 0–14 already given in the test set, and it is not a titer-per-day prediction. - Even though zero-padding technique to make experiment 14-day rows is tempting here because it will make training set shape match the test set shape, but it can cause more problems; e.g. it creates spurious features and zeroes have no meaningful physical meaning. Therefore, I decided to go for one row per experiment by compressing an entire experiment feature into a single summary (see an example of transformed dataframe in the figure above).
-
Parameter mismatches within the dataset: For example, in the first experiment, final Temp on day 0 (
35.07070707) does not match the temperature on last day (37.28282828). In fact, they are supposed to be the same value. -
Overfitting: Small amount of training samples (
$N = 100$ ) could make model prone to overfitting. I first start with different models and evaluate them using the cross-validation technique: linear/optimization-based models like partial least squares (PLS)/regularized linear models, multiple linear regression (MLR), tree-based models like Random Forest (RF)/Gradient Boosting, XGBoost, and probabilistic-based models like Gaussian Process (GP).
The following are two new, important, domain-based features computed in EDA from time-series observations
Quantifies overall net logarithmic cell growth rate across the experiment duration:
Identifies the day when viable cell density reaches its maximum, marking the transition into the stationary/death phase:
Note that these two features are highly correlated.
A list of 18 non-redundant filtered features selected via target correlation (
'VCD_time_to_peak', 'Lysed_slope', 'VCD_auc',
'Lac_auc', 'Z:ExpDuration', 'FeedGlc_auc',
'FeedGln_slope', 'VCD_final', 'FeedGln_auc',
'Lac_slope', 'FeedGln_final', 'ph_shift_reached',
'pH_final', 'FeedGlc_final', 'temp_final',
'temp_shift_reached', 'Glc_final', 'Z:tempStart'
- Start with PLS regression on the engineered per-experiment feature table as an interpretable benchmark, tuning the number of latent components by cross-validated
$R^2$ /MAE. - Use Gradient Boosting or Random Forest as the primary predictive model, because it captures nonlinearity without overfitting on 100 samples when combined with shallow trees, few boosting rounds/strong shrinkage, and rigorous CV (repeated K-fold given the small N).
- Use physically meaningful features: AUC/final-value summaries, and do not use dozens of redundant statistics.
- Use PCA to analyze and/or reduce dimensionality of correlated features and identify the most important features.
- Consider a Gaussian Process because it is an uncertainty-aware model.
- Consider a hybrid mechanistic + ML model - it is the most scientific option for a process (especially for bioprocessing) with known cell-growth kinetics.
- We should use deep learning (e.g. LSTM or GRU) when we have more training data, or at least use a small model architecture.
I evaluated baseline models under 5-fold
| Model |
|
|
RRMSE (47 features) | RRMSE (18 filtered features) | |
|---|---|---|---|---|---|
| PLS (5 comp.) | 0.7244 | 0.7714 | +0.0470 | 26.8% | 24.6% |
| Ridge Regression | 0.7616 | 0.7679 | +0.0063 | 25.1% | 25.0% |
| MLR | 0.6243 | 0.7238 | +0.0995 | 30.6% | 27.2% |
| Random Forest | 0.7311 | 0.7135 | -0.0176 | 27.6% | 28.6% |
| Gradient Boosting | 0.7925 | 0.7088 | -0.0837 | 24.4% | 29.0% |
| XGBoost | 0.7264 | 0.6486 | -0.0778 | 28.0% | 30.9% |
See baseline.ipynb for baseline model and test_template.ipynb for test template.
-
PLS, Ridge, and MLR perform best on the 18 filtered feature set:
- PLS
$R^2$ reaches 0.771 with lowest RRMSE 24.6%. - Unregularized MLR
$R^2$ increases from 0.624 to 0.724 (+10 percentage points).
- PLS
- Tree Ensembles (GB 0.793, RF 0.731) perform best on the full 47-feature set where decision tree splits handle non-linear interactions across all raw variables.
-
Best baseline model: PLS with 5 components (
$\text{R}^2 = 0.7714$ ,$\text{RRMSE} = 24.6%$ ). - Reason: It handles multicollinearity in bioprocess features effectively.
- OpenAPI YAML vs. JSON DTO: Sample inference input data is provided as an example in an OpenAPI spec file (
inference_server_spec.yml), whereas the FastAPI server requires a JSON payload matching a Pydantic DTO. Parsing YAML file on every server request introduces unnecessary disk I/O and heavy parsing overhead on the API server. - To solve this problem, I use a separate script
spec_yml_to_json.pyto extract the sample experiment payload into a JSON file (payload.json). So clients can send standard JSON POST requests at runtime directly to/predict, keeping the microservice fast. In addition, I also implemented a/predict/fileendpoint infeat/yaml-file-predictionbranch as an alternative, which allows clients to upload.ymlfiles directly. This endpoint uses FastAPI'sUploadFileto receive.ymlfiles and parse them through the Pydantic DTO.
I use MLflow to track model training parameters, evaluation metrics, and model artifacts, and to log inference results.
Run model training with MLflow logging:
python -m ml.train_modelLaunch the MLflow UI:
mlflow uiThen navigate to http://127.0.0.1:5000 in your browser to view the MLflow dashboard.
Tech stack
- Environment: Python 3.11+, uv, ruff
- Data processing: NumPy, Pandas, Scikit-learn
- Statistical inference: MAPIE
- Model development: Scikit-learn, XGBoost, MLflow
- Experiment tracking: MLflow
- Inference microservice: Docker, FastAPI, Uvicorn, PyYAML
- DevOps: CI/CD, GitHub Actions, Pydantic, Pytest
- Workflow tracking: Notion
[ Client Inference Request ]
| (HTTP POST /predict with JSON payload*)
v
[ FastAPI API Gateway ]
(Validates request schema via Pydantic DTO)
│
v
[ Data preprocessing & Feature engineering ]
(Reconstructs experiment time-series DataFrame)
(Computes slopes, AUCs, etc. )
(Filters 18 non-redundant features)
│
v
[ Inference & Uncertainty ]
(Deserializes trained model via Joblib)
(Executes scalar mAb Titer prediction)
(Calculates 95% CI uncertainty via MAPIE)
│
v
[ Detailed JSON Response ]
*JSON payload can be generated from the spec_yml_to_json.py script.
-
API Gateway (
FastAPI,Pydantic,Uvicorn,Docker)- Receive requests at
/predictwith bioprocess daily time-series data (timestampsand parametervalues). - Use Pydantic DTO to enforce strict input data validation and type checking.
- Serve asynchronously via Uvicorn server containerized inside Docker.
- Receive requests at
-
Feature Engineering Pipeline
- Data Transformation (
request_to_exp_dataframe): Convert JSON payload into structured pandas DataFrames per experiment. - Feature Extraction (
build_feature_table): Dynamically compute meaningful features including kinetic features. - Feature Selection: Select the top 18 non-redundant features.
- Data Transformation (
-
Inference & Conformal Uncertainty (
MAPIE,Joblib,Scikit-learn/XGBoost)- Load serialized model artifacts (
models/*.joblib) into memory at server startup using Joblib. - Use MAPIE (
SplitConformalRegressor) to calculate model-agnostic prediction uncertainty and 95% confidence intervals. - Return a JSON response containing titer prediction, margin of error (
uncertainty), confidence bounds, and model metadata.
- Load serialized model artifacts (
Rangsiman Ketkaew


