# From Notebook to Service: Turning a Machine Learning Model Into Something Actually Usable
## The Journey That Led Here
Several months ago, I set out on a personal challenge: transition from a data analytics background into data engineering. Along the way, I built a couple of hands-on projects that shaped how I think about data work today. One was an automated pipeline that pulls repository data from a code-hosting platform and loads it into a local database on a regular schedule. Another extracted articles from news feeds and stored them in an orchestration tool’s database, running hourly.
After getting comfortable with the fundamentals of extracting, transforming, and loading data, I wanted to stretch further. I’d always been intrigued by machine learning but assumed it required intimidating amounts of advanced mathematics. It turned out that wasn’t quite the barrier I’d imagined.
### Building a Churn Prediction Model
For my latest project, I constructed a churn prediction model for a fictional telecommunications company I’ll refer to as Northline Mobile. I chose a fictional scenario because I find that real-world context helps me internalize concepts far more effectively than abstract exercises ever could.
The dataset contained information about over seven thousand customers — details like contract type (annual versus month-to-month), tenure, monthly charges, add-on services, payment methods, and whether or not the customer ultimately left. The model was trained to recognize patterns in that data and predict which customers were likely to churn. I validated it using held-out customer records it had never seen during training.
The final accuracy landed at roughly 81%. More importantly, the process taught me what goes into building a machine learning model from start to finish. I won’t pretend the code was all intuitive — I lean toward visual, drag-and-drop interfaces over writing complex code from scratch. But I did grasp the essential components: the preprocessing steps that clean raw data, the training workflow, and the evaluation metrics that tell you whether the model is actually learning anything meaningful.
The model itself worked. That was the easy part.
## The Problem Nobody Talks About
Here’s where things got interesting. Let’s say Northline’s retention team builds a customer dashboard and wants it to automatically flag at-risk accounts. That dashboard can’t reasonably open a Jupyter notebook, run cells in a specific order, and manually invoke a prediction function. It needs something fundamentally different — a way to send customer data to the model and receive a prediction back, programmatically, without any human intervention.
This is the gap between *having a model* and *having a service*.
A model sitting quietly inside a notebook is only as useful as the person who built it. The moment you wrap it in an API, it becomes something any application, dashboard, or team can consume — without needing to know or care how the prediction is actually calculated.
Ironically, building the machine learning model turned out to be the simpler piece. Making it genuinely accessible to other software required an entirely different set of engineering decisions.
## Designing the Boundary
Before writing a single line of API code, I had to answer a more fundamental question: what should the interface between my software and my model actually look like?
I considered two approaches. One was a simplified version that accepts only a handful of fields and trains the model fresh each time. The other was full fidelity — the API accepts every raw field that Northline’s existing systems would realistically possess about a customer, matching the exact columns from the original dataset. I went with full fidelity.
A typical request to the endpoint looks something like this:
“`json
{
“gender”: “Female”,
“SeniorCitizen”: 0,
“Partner”: “Yes”,
“Dependents”: “No”,
“tenure”: 12,
“PhoneService”: “Yes”,
“MultipleLines”: “No”,
“InternetService”: “Fiber optic”,
“OnlineSecurity”: “No”,
“OnlineBackup”: “Yes”,
“DeviceProtection”: “No”,
“TechSupport”: “No”,
“StreamingTV”: “Yes”,
“StreamingMovies”: “No”,
“Contract”: “Month-to-month”,
“PaperlessBilling”: “Yes”,
“PaymentMethod”: “Electronic check”,
“MonthlyCharges”: 75.50,
“TotalCharges”: 890.50
}
“`
The response, by design, is deliberately compact:
“`json
{
“churn_probability”: 0.3136,
“prediction”: 0,
“risk_level”: “Medium”
}
“`
One detail worth calling out: the `risk_level` field isn’t something the model generates. The model only produces a raw probability number. A value like 0.31 isn’t something a retention specialist can act on at a glance, so I added a simple classification layer — below 0.3 is Low risk, 0.3 to 0.6 is Medium, and above 0.6 is High. These thresholds are a reasonable starting point, not something I derived through rigorous statistical analysis, and I want to be upfront about that rather than overstate their precision.
This boundary — the input schema, the output schema, what’s required, what gets rejected — turned out to be the most important design decision of the entire project. Once I had it clearly defined, the actual endpoint was almost straightforward by comparison.
## Preparing the Model for Life Beyond the Notebook
There’s a step between “a request arrives” and “a prediction comes back” that’s surprisingly easy to overlook: the raw JSON coming into the API looks nothing like what the model actually expects internally.
Here’s what the journey looks like:
– A JSON request arrives with human-readable field names and values.
– The API passes it through preprocessing that mirrors exactly what happened during training.
– The preprocessed data gets shaped into the format the model was trained on.
– The model produces a prediction.
– The prediction gets wrapped in a clean response and returned.
The critical insight here is that the API cannot invent its own version of preprocessing. Whatever transformations were applied during training must happen identically at inference time. If tenure and monthly charges were scaled a certain way during training and the API applies a different scaling — or forgets scaling entirely — the model will receive numbers it has never seen the shape of before. And here’s the unsettling part: it won’t throw an error. It will simply produce a silently wrong prediction.
To prevent this, I built a single preprocessing module that was imported by both the training script and the live API. This shared module caught a subtle bug early on: during training, a binary encoding function mapped “Yes” and “No” values to 1 and 0 across several columns, including the target column `Churn`. But a live prediction request obviously doesn’t contain a `Churn` column — that’s what we’re trying to predict. Running the training-time function against a request would crash when it looked for a column that was never going to exist. The fix was a simple guard check for column existence, but it’s exactly the kind of issue that only surfaces when you try to run training-time code at inference time.
## Structuring the Project
The final project layout looked something like this:
“`
churn-service/
├── data/
├── notebooks/
│ └── 01_exploration.ipynb
├── app/
│ ├── main.py
│ ├── schemas.py
│ ├── model.py
│ └── preprocessing.py
├── models/
│ ├── pipeline.pkl
│ ├── scaler.pkl
│ └── feature_columns.pkl
├── train.py
└── requirements.txt
“`
Two architectural decisions here are worth highlighting. First, the training script lives at the project root, separate from the `app/` directory, which is specifically the code that runs the live service. Training is a distinct process that produces the artifacts the service depends on — the service itself doesn’t need to know or care how it was trained. Second, the training script reaches into `app/` to reuse the preprocessing module, not the other way around. This keeps the service cleanly decoupled from the training workflow.
## Loading the Model Once
One decision that seemed obvious only after I nearly got it wrong: where does the model get loaded?
The tempting approach — and the wrong one — is loading it inside the prediction function itself, so every single request re-reads the model files from disk. That’s slow and wasteful.
The correct approach is loading the model once, when the module is first imported. By the time the API is actively serving requests, the model is already sitting in memory, ready to go. This is a small detail, but it makes the difference between an API that works on paper and one that can handle real traffic without falling over.
## The Prediction Endpoint
With the model loaded once and preprocessing shared with the training pipeline, the actual endpoint turned out to be remarkably lean:
The route function’s sole responsibility is receiving a validated request and passing it to the prediction logic. All the real work — preprocessing, feature alignment, model invocation — lives in separate modules. I deliberately kept business logic out of the HTTP layer. The endpoint should be thin plumbing, not a dumping ground for application logic.
One detail that took me longer than it should have: when serving a single prediction request, each one-hot encoded category can only ever produce one value. During training, the dataset might generate four dummy columns for a categorical feature across thousands of rows, but a single customer’s request can only represent one value for that feature. To handle this, I reindexed the request’s columns against the exact feature list the model was trained on, filling in zeros for anything missing. Without this step, a single request’s column layout wouldn’t reliably match what the model expects, and the underlying library would either error out or silently misalign features. This is the kind of issue that never surfaces when testing on a full dataset — it only appears when you send the model exactly one row at a time.
## Testing Like Real Software
Getting a successful response from the interactive API documentation was not the finish line I had imagined. The more interesting question was what happens when the input isn’t clean.
I sent a request with the `tenure` field completely missing. The API rejected it before the model ever saw the data, returning a clear, specific error message pinpointing exactly which field was absent.
I sent `gender` in lowercase as “female” instead of the expected “Female.” Again, rejected — with the exact reason stated plainly.
Neither of these malformed requests ever reached the prediction logic. That’s the whole point. The schema isn’t just documentation sitting alongside the code — it functions as a real gatekeeper. Bad input gets a clear error back immediately, not a confusing model failure buried three layers deep, and certainly not a silently incorrect prediction because the model was handed data it was never trained to interpret.
## The Transformation
Before this project, getting a prediction meant opening a Jupyter notebook, running the right cells in the correct order, and making sure the right variables were still in memory from earlier in the session.
Now, it’s a single HTTP request from anywhere. A command-line tool, a completely separate application, a dashboard built by someone who has never seen my code, never installed pandas, never heard of the machine learning library I used — they can all get a churn prediction out of this model.
The model didn’t get any smarter. It became something other software could actually use. And that distinction is everything.
## Honest Limitations
I want to be straightforward about what isn’t solved yet. The model and API work, but only on my own machine. If someone else tried to run this exact project, there’s no guarantee it would function — different Python versions, different package installations, different environment configurations could all introduce silent failures. Right now, “it works” really means “it works on my machine, under my specific setup.”
There’s also no cloud deployment yet. The API isn’t reachable from outside my own network. If my laptop goes offline, the service goes offline with it.
I’m not addressing any of that in this article. That’s genuinely material for a future piece. What I wanted to establish first was that the application itself — the boundary, the contract, the validation layer — was solid before adding infrastructure on top. Putting a container and cloud deployment around something with a shaky foundation just means the shaky part becomes harder to diagnose.
## Key Takeaways
1. **A working model is not automatically a usable one.** Good evaluation metrics tell you the model learned something meaningful. They don’t tell you that someone else can send it data and get a prediction back. Turning a model into something others can actually use requires an additional layer of engineering effort.
2. **The API is a contract, not just a wrapper.** I initially assumed the API would be a thin layer around the model. In practice, deciding what a request should contain, what the API should reject, and what it should return turned out to be just as important as the prediction itself.
3. **Inference introduces its own engineering challenges.** Getting a model to predict inside a notebook is relatively straightforward. Making those predictions reliable through a service introduces a different set of concerns — preprocessing must match training exactly, the model should be loaded once rather than per-request, and even how a single request is structured can matter enormously.
4. **Local-first development surfaces real problems early.** Issues like missing columns, feature misalignment, and data shape mismatches have nothing to do with the cloud. They’re problems in the application itself. Discovering them locally is far better than finding them after adding containers, cloud infrastructure, and deployment complexity on top.
5. **A clear boundary makes future deployment easier.** I don’t yet know exactly what challenges the next phase will bring once deployment enters the picture. But I do know the application has a well-defined shape now: data comes in, it gets validated and prepared, the model produces a prediction, and a structured response goes back out. Whatever breaks next, at least it won’t be because I never established what the API was supposed to do.
## FAQ
**Q: Why did you build a fictional company for the churn prediction project?**
A: Working with a fictional scenario helps isolate the learning experience. Without the noise of a real organization’s politics, legacy systems, and messy stakeholder requirements, you can focus entirely on the technical and conceptual challenges of the work itself.
**Q: What’s the difference between a model and a service?**
A: A model is a mathematical construct that produces predictions when given data. A service is a runnable, accessible piece of software that wraps that model in a predictable interface — accepting requests, validating input, running preprocessing, returning structured output. A service can be consumed by other applications; a model sitting in a notebook cannot.
**Q: Why is preprocessing consistency between training and inference so important?**
A: A machine learning model learns patterns based on the exact format of data it was trained on. If the data it receives at prediction time looks different — even slightly — the patterns it relies on no longer apply. The model won’t necessarily crash, but its predictions will be unreliable, and the errors will be difficult to trace.
**Q: What tool did you use to build the API?**
A: The service was built using a popular Python web framework known for its speed and automatic API documentation. The framework itself is not the most interesting part — the interesting part is the design decisions around what the API accepts, rejects, and returns.
**Q: Isn’t 81% accuracy good enough?**
A: Accuracy is one metric among many, and it can be misleading depending on the class distribution in the data. For this project, 81% was a reasonable starting point that demonstrated the model had learned meaningful patterns. For production use, additional metrics like precision, recall, and false positive rates would need to be evaluated in context with the business cost of different types of errors.
**Q: What’s the next step after building the API locally?**
A: The logical next phase is containerizing the application and deploying it to a cloud platform so it’s accessible beyond a single machine. That involves decisions about environment management, networking, scaling, and monitoring — all topics worth exploring separately.
**Q: Do I need to be an expert in machine learning to build an API around a model?**
A: Not necessarily. The skills required to wrap a model in a service — API design, input validation, error handling, preprocessing pipelines — are more software engineering skills than machine learning skills. You don’t need to understand the math behind the model to make it accessible to other applications.
## Conclusion
The gap between “I built a model that works” and “I built something others can actually use” is where most of the real engineering lives. It’s not glamorous, and it doesn’t make for impressive demo videos. But it’s the difference between a proof of concept and a production asset.
This journey taught me that defining clear boundaries — what goes in, what comes out, what gets rejected, and how preprocessing is handled — is just as critical as choosing the right model or tuning the right hyperparameters. The model is the brain. But the API is the voice that lets the rest of the world actually hear what it has to say.
There’s still plenty of work ahead — cloud deployment, containerization, monitoring, handling scale. But establishing a solid foundation first means those next steps will be built on something reliable rather than something fragile.
Thank you for reading



