From Jupyter Notebook to Live API: Building a Production-Grade ML System for Hospital Readmission… A developer built a production-grade machine learning system to predict 30-day hospital readmissions using the UCI Diabetes 130-US Hospitals dataset, which contains 101,766 patient encounters from 130 US hospitals. The system includes preprocessing, XGBoost training with MLflow tracking, a FastAPI backend, SQLite logging, Docker deployment on Render, Evidently AI drift monitoring, and a Streamlit front end. The project addresses class imbalance (11% readmission rate) and aims to ship a usable ML system rather than just a model. I've built ML models before. Trained them, evaluated them, gotten a decent AUC, and then... saved a pickle file and moved on. The model worked. Nobody ever used it. This time I wanted to do it differently. I wanted to build something that actually runs on the internet, accepts real requests, logs predictions, detects when it starts failing, and has a front end a non-engineer could use. In other words, I wanted to build what an ML Engineer actually ships — not just a model, but a system . This post is the honest account of how that went: what I built, every tool I used, the errors that genuinely stumped me, and what I learned from each one. Hospital readmissions within 30 days are expensive, preventable, and measurable. The US Centers for Medicare & Medicaid Services financially penalizes hospitals with high readmission rates — so predicting which patients are at risk isn’t just academically interesting, it has real operational value. The dataset: UCI Diabetes 130-US Hospitals — 101,766 patient encounters from 130 US hospitals collected over 10 years. The target: was a patient readmitted within 30 days of discharge? I chose this problem deliberately. Healthcare data is tabular and realistic. The business context is legible to any interviewer. And the class imbalance only 11% of patients are readmitted within 30 days gives you something interesting to reason about. But mostly: I wanted to focus my learning on the engineering layer , not the modeling, so I needed a problem where the model itself could be relatively straightforward. Before any code, here’s the full picture of what I built: Raw data UCI Dataset │ ▼preprocess.py — clean, encode, split 70/15/15 │ ▼train.py — XGBoost × 3 experiments → MLflow tracking │ ▼MLflow Model Registry @champion alias │ ▼FastAPI — /health, /predict, /predict batch │ ├──► SQLite prediction logging │ ▼Docker container │ ▼Render live API — render.com │ ▼Evidently AI — data drift monitoring │ ▼Streamlit front end — deployed on Streamlit Community Cloud Every piece of that pipeline is something I had to build, debug, and understand from first principles. Let me walk through each one. The raw dataset has 50 columns and a few challenges worth flagging: Missing values disguised as question marks. The dataset uses ? instead of NaN for unknown values. A standard .isnull check returns almost nothing — you have to explicitly replace ? with np.nan first, otherwise you're feeding the string "?" into your model and wondering why things look weird. df = df.replace '?', np.nan The target has three categories, not two. The readmitted column contains NO, <30, and 30. We only care about 30-day readmissions — so I converted this to a binary target: df 'readmitted 30' = df 'readmitted' == '<30' .astype int Age is stored as ranges. 50-60 isn't a number a model can work with. I mapped each bracket to its midpoint: age map = {' 0-10 ': 5, ' 10-20 ': 15, ..., ' 90-100 ': 95}df 'age' = df 'age' .map age map 23 medication columns with four possible values each. Rather than one-hot encoding four states per medication adding 92 columns , I simplified to binary: is the patient on this medication at all? for col in med cols: df col = df col .apply lambda x: 0 if x == 'No' else 1 After preprocessing and a stratified 70/15/15 split: 71,236 training rows, 15,265 validation, 15,265 test. Stratified means each split preserves the same ~11%/89% class ratio. With only 11% positive cases, a model that just predicts “not readmitted” for everyone would be 89% accurate and completely useless. This is the trap of using accuracy on imbalanced data. XGBoost has a built-in solution: scale pos weight. You pass it the ratio of negative to positive examples, and the model penalizes errors on the minority class proportionally: neg = y train == 0 .sum pos = y train == 1 .sum scale pos weight = neg / pos ≈ 7.96 I chose this over SMOTE deliberately. SMOTE generates synthetic data points by interpolating between minority-class neighbors — but with 23 binary medication columns, you’d end up with synthetic patients where insulin = 0.6, a value that doesn't exist in reality. For mixed binary/categorical data, adjusting the loss function is cleaner than fabricating rows. Without experiment tracking, comparing three model configurations means: run the code, write the metrics somewhere, forget which run used which settings, repeat. MLflow automates the notebook entirely: mlflow.set experiment "readmission-prediction" with mlflow.start run : mlflow.log params {"n estimators": 200, "max depth": 4, "learning rate": 0.1} model = XGBClassifier params, scale pos weight=7.96 model.fit X train, y train auc = roc auc score y val, model.predict proba X val :, 1 mlflow.log metric "val auc", auc mlflow.sklearn.log model model, "model", registered model name="readmission-model" After three runs, the MLflow dashboard mlflow ui → localhost:5000 showed this comparison: Run 2 won on AUC. I promoted it to the model registry with an alias: readmission-model → Version 2 → alias: "champion" The API loads it by name: models:/readmission-model@champion. This decouples "which model is deployed" from the application code — promoting a better model later requires no code changes. 0.682 is honest, not impressive. The main reason: I dropped the three diagnosis columns diag 1, diag 2, diag 3 , which each contain 700+ unique ICD codes requiring more sophisticated encoding. Those columns are likely the most clinically predictive features in the dataset. Re-incorporating them with proper encoding grouping by clinical category, for example is the clearest path to improvement. For this project, the priority was the engineering pipeline — the model was a means, not the end. A trained model sitting in MLflow is useless unless something can call it. FastAPI makes building the serving layer straightforward — but the details matter. Pydantic validation means bad requests get rejected before they reach the model. If someone sends "three" for time in hospital, they get a clear 422 error immediately — not a cryptic model failure downstream. The /health endpoint is standard practice. Deployment platforms, load balancers, and monitoring tools ping it to check the service is alive. It's two lines of code and its absence is noticed. Logging every prediction to SQLite: python def log to db patient data: dict, probability: float, prediction: int : conn = sqlite3.connect "predictions.db" record = patient data.copy record 'probability' = probability record 'timestamp' = datetime.utcnow .isoformat pd.DataFrame record .to sql "predictions", conn, if exists="append", index=False conn.close SQLite is a single file, no server, no setup. For a single-instance API it’s fine. At scale multiple API instances, concurrent writes , you’d switch to PostgreSQL. But for a portfolio project, this is the pragmatic choice — and being able to explain why it’s pragmatic, and what you’d change at scale, is itself an interview point. This is where the project got interesting. error during connect: this error may indicate that the docker daemon is not running Rancher Desktop which provides Docker on my machine wasn’t running. Start it, set it to dockerd moby engine, wait for it to initialize. Fixed. pip freeze on Windows captures everything — including Windows-specific packages like pywin32 that don't exist on Linux, which is what Docker containers run. The fix: delete that line from requirements.txt. Lesson: pip freeze is a complete snapshot, not a curated dependency list. For production, you'd maintain a hand-written requirements.txt with only what you actually need. mlflow.exceptions.MlflowException: Registered Model with name=readmission-model not found This error appeared twice, for two completely different reasons. Here’s what made it genuinely tricky. MLflow stores two separate things: My Dockerfile copied mlruns/ but not mlflow.db. The container had the model files but no record of what they were called or which version was "champion." Same error as if the model didn't exist. Fixed by adding to the Dockerfile: COPY mlflow.db ./mlflow.dbENV MLFLOW TRACKING URI=sqlite:////app/mlflow.db Then the error appeared again . Same message. Same model. The cause this time: a single missing slash. SQLite URIs for absolute paths need four slashes: sqlite:////app/mlflow.db ← correct 4 slashes sqlite:///app/mlflow.db ← wrong 3 slashes — treated as relative path With three slashes, MLflow couldn’t find the file and silently created a new, empty database instead. Fixing the slash count fixed everything. The big lesson: most deployment errors are about configuration and file paths, not about your model or application logic. The model worked correctly the entire time. Five separate errors, all configuration. This is representative of real ML engineering work. Render builds your Docker image directly from your GitHub repository and gives you a public URL. The process was mostly smooth with two small catches: Catch 1: When creating the service, I accidentally typed main the branch name into the "Root Directory" field instead of leaving it blank. Render looked for a folder named main/ inside the repo, found nothing, failed. Fixed by clearing the field. Catch 2: mlruns/ and mlflow.db were in .gitignore from an earlier project template — so they never made it to GitHub, and Render's build would hit the same "model not found" error again. Fixed by removing them from .gitignore and committing. At under 1MB total, they're small enough to commit directly — for larger models, you'd use a remote model store like S3. The free tier spins down after 15 minutes of inactivity and takes 30–60 seconds to wake up. Worth mentioning when sharing the link. A deployed model isn’t a finished model. Patient populations shift. Hospital policies change. A model trained on 2015–2019 data may degrade on 2024 data without any code ever changing. Detecting this early is the job of monitoring. Evidently AI compares two datasets column by column using statistical tests and produces an HTML report: python from evidently import Reportfrom evidently.presets import DataDriftPreset report = Report metrics= DataDriftPreset result = report.run reference data=X train, current data=X test result.save html "monitoring/drift report.html" Baseline report training vs. test data from the same split : 0 drifted columns out of 46. Correct — they came from the same underlying distribution. This confirmed the pipeline works. Simulated drift report artificially aging the test patients by 15 years and extending hospital stays by 50% : exactly age and time in hospital flagged as "Detected" with drift scores of 0.83 and 0.74. Every other column: "Not Detected." Having both reports matters. A single “no drift” result doesn’t prove anything — it could mean the monitoring isn’t sensitive. A clean baseline plus a correctly-triggered detection proves the system works in both directions. The API is great for programs calling it. For a human user, there’s now a Streamlit interface: The batch endpoint exists because calling /predict 1,000 times is 1,000 separate network round-trips. /predict batch handles a list in a single request, which is how real bulk-scoring workflows should work. The Streamlit app is deployed separately on Streamlit Community Cloud and calls the live Render API under the hood. This is the standard architecture: a front end as a client of a back end API, built and deployed independently. The model is not the product. The system around it is. AUC 0.682 on a deliberately simplified model isn’t impressive on its own. But a live API, containerized and deployed, with experiment tracking, model versioning, prediction logging, data drift monitoring, and a usable front end — that’s a different conversation. The skills I exercised in this project — debugging Docker environments, understanding MLflow’s internal storage model, thinking about monitoring before things break — are the skills that distinguish an ML Engineer from someone who trains models. That gap is what this project was designed to close. GitHub: github.com/abhishekkk-y/patient-readmission-api Live API: patient-readmission-api-psbw.onrender.com/docs From Jupyter Notebook to Live API: Building a Production-Grade ML System for Hospital Readmission… https://pub.towardsai.net/from-jupyter-notebook-to-live-api-building-a-production-grade-ml-system-for-hospital-readmission-c5a7dae286f1 was originally published in Towards AI https://pub.towardsai.net on Medium, where people are continuing the conversation by highlighting and responding to this story.