{"slug": "from-jupyter-notebook-to-live-api-building-a-production-grade-ml-system-for", "title": "From Jupyter Notebook to Live API: Building a Production-Grade ML System for Hospital Readmission…", "summary": "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.", "body_md": "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.\n\nThis 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*.\n\nThis 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.\n\nHospital 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.\n\nThe 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?\n\nI 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.\n\nBefore any code, here’s the full picture of what I built:\n\n```\nRaw 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\n```\n\nEvery piece of that pipeline is something I had to build, debug, and understand from first principles. Let me walk through each one.\n\nThe raw dataset has 50 columns and a few challenges worth flagging:\n\n**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.\n\n```\ndf = df.replace('?', np.nan)\n```\n\n**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:\n\n```\ndf['readmitted_30'] = (df['readmitted'] == '<30').astype(int)\n```\n\n**Age is stored as ranges.** [50-60) isn't a number a model can work with. I mapped each bracket to its midpoint:\n\n```\nage_map = {'[0-10)': 5, '[10-20)': 15, ..., '[90-100)': 95}df['age'] = df['age'].map(age_map)\n```\n\n**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?\n\n```\nfor col in med_cols:    df[col] = df[col].apply(lambda x: 0 if x == 'No' else 1)\n```\n\nAfter 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.\n\nWith 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.\n\nXGBoost 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:\n\n```\nneg = (y_train == 0).sum()pos = (y_train == 1).sum()scale_pos_weight = neg / pos  # ≈ 7.96\n```\n\nI 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.\n\nWithout 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:\n\n```\nmlflow.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\")\n```\n\nAfter three runs, the MLflow dashboard (mlflow ui → localhost:5000) showed this comparison:\n\nRun 2 won on AUC. I promoted it to the model registry with an alias:\n\n```\nreadmission-model → Version 2 → alias: \"champion\"\n```\n\nThe 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.\n\n0.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.\n\nA trained model sitting in MLflow is useless unless something can call it. FastAPI makes building the serving layer straightforward — but the details matter.\n\n**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.\n\n**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.\n\n**Logging every prediction to SQLite:**\n\n``` python\ndef 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()\n```\n\nSQLite 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.\n\nThis is where the project got interesting.\n\n```\nerror during connect: this error may indicate that the docker daemon is not running\n```\n\nRancher Desktop (which provides Docker on my machine) wasn’t running. Start it, set it to dockerd (moby) engine, wait for it to initialize. Fixed.\n\npip 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.\n\n```\nmlflow.exceptions.MlflowException: Registered Model with name=readmission-model not found\n```\n\nThis error appeared twice, for two completely different reasons. Here’s what made it genuinely tricky.\n\nMLflow stores two separate things:\n\nMy 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.\n\nFixed by adding to the Dockerfile:\n\n```\nCOPY mlflow.db ./mlflow.dbENV MLFLOW_TRACKING_URI=sqlite:////app/mlflow.db\n```\n\nThen the error appeared *again*. Same message. Same model.\n\nThe cause this time: a single missing slash. SQLite URIs for absolute paths need **four** slashes:\n\n```\nsqlite:////app/mlflow.db  ← correct (4 slashes)sqlite:///app/mlflow.db   ← wrong (3 slashes — treated as relative path)\n```\n\nWith three slashes, MLflow couldn’t find the file and silently created a new, empty database instead. Fixing the slash count fixed everything.\n\n**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.\n\nRender builds your Docker image directly from your GitHub repository and gives you a public URL. The process was mostly smooth with two small catches:\n\n**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.\n\n**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.)\n\nThe free tier spins down after 15 minutes of inactivity and takes 30–60 seconds to wake up. Worth mentioning when sharing the link.\n\nA 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.\n\nEvidently AI compares two datasets column by column using statistical tests and produces an HTML report:\n\n``` python\nfrom evidently import Reportfrom evidently.presets import DataDriftPreset\nreport = Report(metrics=[DataDriftPreset()])result = report.run(reference_data=X_train, current_data=X_test)result.save_html(\"monitoring/drift_report.html\")\n```\n\n**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.\n\n**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.\"\n\nHaving 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.\n\nThe API is great for programs calling it. For a human user, there’s now a Streamlit interface:\n\nThe 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.\n\nThe 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.\n\nThe model is not the product. The system around it is.\n\nAUC 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.\n\nThe 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.\n\n**GitHub:** github.com/abhishekkk-y/patient-readmission-api **Live API:** patient-readmission-api-psbw.onrender.com/docs\n\n[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.", "url": "https://wpnews.pro/news/from-jupyter-notebook-to-live-api-building-a-production-grade-ml-system-for", "canonical_source": "https://pub.towardsai.net/from-jupyter-notebook-to-live-api-building-a-production-grade-ml-system-for-hospital-readmission-c5a7dae286f1?source=rss----98111c9905da---4", "published_at": "2026-08-25 18:31:01+00:00", "updated_at": "2026-08-25 18:44:30.327833+00:00", "lang": "en", "topics": ["machine-learning", "mlops", "ai-products", "ai-infrastructure"], "entities": ["UCI Diabetes 130-US Hospitals", "XGBoost", "MLflow", "FastAPI", "SQLite", "Docker", "Render", "Evidently AI"], "alternates": {"html": "https://wpnews.pro/news/from-jupyter-notebook-to-live-api-building-a-production-grade-ml-system-for", "markdown": "https://wpnews.pro/news/from-jupyter-notebook-to-live-api-building-a-production-grade-ml-system-for.md", "text": "https://wpnews.pro/news/from-jupyter-notebook-to-live-api-building-a-production-grade-ml-system-for.txt", "jsonld": "https://wpnews.pro/news/from-jupyter-notebook-to-live-api-building-a-production-grade-ml-system-for.jsonld"}}