Home / Basics of AI/ML / Basics of AI/ML
Basics of AI/MLHow-toBeginner
How to Build Your First Machine Learning Model in Python: A Beginner's Guide (Food Delivery ML Series, Part 1)
The short answer
You can build a working machine learning model in about 40 lines of Python. In this guide you will train one that predicts whether a food delivery will arrive late, correctly 92% of the time.
Quick answer: To build your first machine learning model in Python: (1) install Python, pandas and scikit-learn, (2) load or create a dataset, (3) split it into training and test sets, (4) choose a model such as logistic regression, (5) train it with.fit(), (6) measure accuracy on the test set, and (7) use.predict()on new data and save the model with joblib.
What you will build
You have ordered food, watched the app say "arriving in 30 minutes", and waited 50. In this guide you will build a model that looks at an order before it leaves the restaurant and predicts: on time or late?
The model gets five simple facts about each order:
| Feature | Example | Why it might matter |
| Distance (km) | 6.0 | Further away takes longer |
| Kitchen preparation time (minutes) | 18 | A slow kitchen eats into the time budget |
| Is it raining? | Yes / No | Riders slow down in the rain |
| Is it peak hour? (lunch or dinner rush) | Yes / No | Traffic and order queues |
| Rider experience (months) | 6 | Experienced riders know shortcuts |
The app promises delivery in 45 minutes. Anything slower counts as late.
Why this example works so well for beginners
- You already understand the problem. No medical, financial or image knowledge needed. You can sanity-check every result with common sense ("of course rain makes it worse").
- The data is readable. It is a simple table of orders, like a spreadsheet, not thousands of pixels.
- We know the true answer. We simulate 2,000 orders using a realistic recipe for delivery time. That lets us check whether the model rediscovers the rules hidden in the data, something you can never do with real-world data.
- It is a real business problem. Food delivery, courier and ride-hailing companies use very similar models to set time estimates and send extra riders when it rains.
- It runs anywhere. Training takes under a second on an ordinary laptop. No downloads, GPU or paid API.
This is supervised learning, specifically binary classification: you show the computer past orders with the right answer (late or not), and it learns to predict the answer for new orders. (If those terms are new, read our beginner's guide to AI vs machine learning vs deep learning vs generative AI first.)
Prerequisites
You need basic Python, a computer or a browser, and about an hour. No maths, GPU or paid account is required.
Knowledge
| Topic | Level needed | Quick self-check |
| Python basics | Required | Can you create a variable, a list and a for loop, and run a .py file? |
| Importing libraries | Required | Do you know what import numpy as np does? |
| Using a terminal | Helpful (not needed with Colab) | Can you open a terminal and change folders with cd? |
| Maths | Not required | Percentages and averages are enough |
| Machine learning | Not required | New to the terms? Read our AI vs Machine Learning vs Deep Learning vs Generative AI guide first (10 minutes) |
New to Python? Spend 2–3 hours on any free Python basics course first (variables, lists, loops, functions). You will then follow this guide comfortably.
Software
| Item | Version | Notes |
| Python | 3.10 or newer | Tested with 3.13 |
| scikit-learn | 1.3 or newer | Tested with 1.9 |
| pandas | 2.0 or newer | Tested with 3.0 |
| NumPy | Any recent | Tested with 2.5 |
| joblib | Any recent | Installed automatically with scikit-learn |
| Jupyter Notebook | Optional | Or use Google Colab, which has everything preinstalled |
Step 1 shows how to install all of these. Your accuracy numbers may differ from ours by a fraction of a percent on other versions.
Hardware and accounts
- Computer: any Windows, Mac or Linux laptop with 4 GB of RAM or more. Training takes seconds; no GPU needed.
- Or a browser only: a free Google account for Google Colab.
- Internet: only to install the libraries. The code creates the dataset itself, so there is nothing to download.
Time and cost
- Time: 45–60 minutes reading every explanation; about 15 minutes if you only run the code.
- Cost: free.
The machine learning workflow at a glance
This is the roadmap for the rest of the guide: one section per step. Steps 4 to 6 form a loop, because real projects train, measure and adjust several times before a model is good enough to use.
Step 1: Set up your environment
You need Python plus four free libraries. The fastest route is Google Colab, which needs no installation at all.
Option A: Google Colab (easiest, recommended for first-timers)
- Go to colab.research.google.com and sign in with a Google account.
- Click New notebook.
- That's it. scikit-learn, pandas, NumPy and joblib are already installed. Type code into a cell and press Shift + Enter to run it.
Option B: Your own computer
- Install Python from python.org (version 3.10 or newer). On Windows, tick "Add Python to PATH" during installation.
- Open a terminal (Command Prompt or PowerShell on Windows, Terminal on Mac/Linux).
- Create a project folder and a virtual environment. A virtual environment keeps this project's libraries separate from everything else on your computer.
mkdir first-ml-model
cd first-ml-model
python -m venv venv
- Activate the environment:
# Windows
venv\Scripts\activate
# Mac / Linux
source venv/bin/activate
You will see (venv) at the start of your terminal line. That means it worked.
5. Install the libraries:
pip install scikit-learn pandas numpy joblib jupyter
6. Start Jupyter Notebook (optional, but it lets you run code piece by piece and see charts inline):
jupyter notebook
What each library does
| Library | Role in this project |
| scikit-learn | The machine learning toolkit: data splitting, models, evaluation |
| pandas | Holds the orders as a table (a DataFrame), like a spreadsheet in Python |
| NumPy | Fast maths on arrays; we use it to simulate the orders |
| joblib | Saves your trained model to a file |
| Jupyter | Interactive notebook to run code step by step |
Check it works: run this in a cell or a Python file.
import sklearn
print(sklearn.__version__)
If you see a version number such as 1.9.1, you are ready.
Step 2: Create and explore the data
We simulate 2,000 food delivery orders; 38% of them arrive late. Rain and rush hour already stand out as warning signs before any machine learning happens.
Real delivery companies have millions of past orders in their databases. We don't, so we create a realistic set in code. The bonus: because we write the recipe, we can later check whether the model figured it out on its own.
The hidden recipe (the model never sees this)
In plain English, each order's delivery time is:
- kitchen prep time, plus
- travel time: about 3 minutes per km, 50% slower in rain, 30% slower at peak hour, minus
- 0.1 minutes for each month of rider experience, plus
- random luck (a traffic light, a lift that takes forever), typically ±5 minutes.
If the total is over 45 minutes, the order is late. The model only gets the five facts and the final answer (late or not). Its job is to work out the pattern from examples, just as it would with real data.
Create the dataset
import numpy as np
import pandas as pd
rng = np.random.default_rng(42) # fixed seed: everyone gets the same orders.
n = 2000
orders = pd.DataFrame({
"distance_km": rng.uniform(0.5, 12, n).round(1),
"prep_time_min": rng.integers(5, 36, n),
"is_raining": (rng.random(n) < 0.2).astype(int), # rains on ~20% of orders
"is_peak_hour": (rng.random(n) < 0.35).astype(int), # ~35% at lunch/dinner rush
"rider_experience_months": rng.integers(0, 49, n),
})
# The hidden recipe for delivery time
travel = orders.distance_km * 3 * (1 + 0.5 * orders.is_raining) * (1 + 0.3 * orders.is_peak_hour)
total = orders.prep_time_min + travel - 0.1 * orders.rider_experience_months + rng.normal(0, 5, n)
orders["is_late"] = (total > 45).astype(int) # 1 = late, 0 = on time
If any line looks unfamiliar, don't worry. You only need the idea: each row is one order, each column is one fact about it, and is_late is the answer we want to predict. Yes/no columns use 1 for yes and 0 for no, because models work with numbers.
Look at the first few orders
orders.head()
Output:
distance_km prep_time_min is_raining is_peak_hour rider_experience_months is_late
0 9.4 14 0 1 19 0
1 5.5 31 0 0 36 1
2 10.4 6 0 0 4 0
3 8.5 18 0 1 44 1
4 1.6 29 1 1 25 0
Read row 1 like a story: a 5.5 km order, a slow kitchen (31 minutes), no rain, off-peak, an experienced rider, and it was late. The slow kitchen used up most of the 45-minute budget. Row 2 is 10.4 km away but the kitchen took only 6 minutes, so it made it on time. Patterns like these are exactly what the model will learn.
How many orders are late?
print(orders.shape)
print(orders["is_late"].value_counts())
(2000, 6)
is_late
0 1238
1 762
2,000 orders, 6 columns. 762 orders (38%) were late and 1,238 (62%) were on time. Remember that 62%; it becomes important in Step 5.
Do rain and rush hour matter?
A quick check with pandas, before any machine learning:
print(orders.groupby("is_raining")["is_late"].mean())
print(orders.groupby("is_peak_hour")["is_late"].mean())
| Condition | Share of orders late |
| Dry weather | 34% |
| Raining | 56% |
| Off-peak | 34% |
| Peak hour | 46% |
Common sense confirmed: rain pushes the late rate from about a third to more than half. This step is called exploratory data analysis, and it is how you catch broken data and build intuition before trusting any model.
Theory break: what is a feature?
A feature is one measurable fact about an example that the model uses to make its prediction. The label is the answer it is trying to predict.
Think about how you would guess whether a delivery will be late. You wouldn't look at the restaurant's logo or the customer's name. You would ask: how far is it, is the kitchen slow, is it raining, is it rush hour? Those questions are features. Machine learning works the same way: the model only knows what the features tell it.
| In our table | Role | Example value |
| Each row | One example (one order) | Order #1 |
distance_km, prep_time_min, is_raining, is_peak_hour, rider_experience_months | Features (inputs, also called X) | 5.5 km, 31 min, no, no, 36 months |
is_late | Label (the answer, also called target or y) | 1 (late) |
Common types of feature:
- Numeric: a number where bigger or smaller means something, such as distance or prep time.
- Yes/no (binary): stored as 1 or 0, such as
is_raining. - Categories: labels with no natural order, such as cuisine type (pizza, biryani, sushi). Models need numbers, so these are converted first, usually into one yes/no column per category. You'll meet this in your next project.
What makes a good feature?
- It is related to the answer. Rain plausibly affects delivery time; the customer's favourite colour does not.
- It is known at prediction time. We want to predict before the order leaves the restaurant, so a feature like "actual minutes the rider took" is forbidden: it secretly contains the answer. Using it is called data leakage, and it produces models that look brilliant in testing and fail in real life.
- It is reliable and consistently recorded. A feature that is missing for half the orders, or measured differently by each restaurant, confuses the model.
Feature engineering means creating better features from raw data. A delivery app's database stores an order timestamp, not "is it peak hour". Turning "19:42 on a Friday" into is_peak_hour = 1 and is_weekend = 0 is feature engineering. In real projects, good features often improve results more than switching to a fancier algorithm.
In Step 4 you'll see which of our five features the model found most important.
Separate the inputs from the answer
X = orders.drop(columns="is_late") # the 5 facts (features)
y = orders["is_late"] # the answer (label)
By convention, ML code uses a capital **X** for the input table and a lowercase **y** for the answers. X has 2,000 rows and 5 columns; y has 2,000 answers.
Step 3: Split the data into training and test sets
Hide 400 of the 2,000 orders from the model during training, then use them at the end to check how well it predicts orders it has never seen.
Think of a student preparing for an exam. If the exam uses the exact questions from the practice book, a student who memorised the answers scores 100% without understanding anything. A fair exam uses new questions. The test set is that fair exam.
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.2, # keep 20% for testing
random_state=42, # makes the shuffle repeatable
stratify=y, # keep the same share of late orders in both sets
)
print("Training:", X_train.shape, "Test:", X_test.shape)
Output:
Training: (1600, 5) Test: (400, 5)
Each argument, explained
| Argument | What it does | Why it matters |
test_size=0.2 | Puts 20% of rows in the test set | 70/30 to 80/20 splits are typical; smaller datasets often keep more for testing |
random_state=42 | Fixes the random shuffle | You get the same split every run, so results are reproducible. Any number works; 42 is a tradition. |
stratify=y | Keeps the share of late orders equal in both sets | Keeps the 38% late rate in both sets, so the test is not accidentally easier or harder |
The function returns four things, always in this order: training inputs, test inputs, training answers, test answers. Mixing up that order is one of the most common beginner bugs.
Golden rule: the model must never see X_test or y_test until the final evaluation. If test data leaks into training, your accuracy score becomes meaningless.
Step 4: Choose and train a model
Training is a single line, **model.fit(X_train, y_train)**. We use logistic regression, and it rediscovers our hidden delivery recipe almost exactly.
from sklearn.linear_model import LogisticRegression
model = LogisticRegression(max_iter=1000)
model.fit(X_train, y_train)
That's it: you have trained a machine learning model. It takes well under a second.
What just happened?
**LogisticRegression(...)**creates an untrained model, like a new dispatcher on their first day.**.fit(X_train, y_train)**shows it all 1,600 training orders with their outcomes.- The model gives each of the five facts a weight: how much that fact pushes an order towards "late". It starts with guesses and keeps adjusting the weights until its predictions on the training orders stop improving.
max_iter=1000allows up to 1,000 rounds of adjustment. - To predict, it adds up the weighted facts for an order and turns the total into a probability between 0% and 100%. Above 50% means "late".
Despite the word "regression" in its name, logistic regression is a classification algorithm: it answers a yes/no question.
Peek inside: what did the model learn?
This is where our example shines. We can print the weights the model learned and compare them with the recipe we hid in Step 2:
for feature, weight in zip(X.columns, model.coef_[0]):
print(f"{feature:25s} {weight:+.2f}")
Output:
distance_km +1.21
prep_time_min +0.33
is_raining +3.08
is_peak_hour +1.97
rider_experience_months -0.04
How to read it: a positive weight pushes an order towards late; a negative weight pushes it towards on time. The bigger the number, the bigger the push for each unit (1 km, 1 minute, rain yes vs no, 1 month).
| What the model learned | Matches the hidden recipe? |
| Rain has the biggest push of all (+3.08) | Yes: rain slows travel by 50% |
| Peak hour matters, but less than rain (+1.97) | Yes: peak slows travel by 30% |
| Each extra km hurts about 3.7× as much as an extra minute of kitchen time (1.21 ÷ 0.33) | Yes: travel costs about 3 minutes per km, more in rain or rush hour |
| Experienced riders are slightly faster (−0.04) | Yes: 0.1 minute saved per month of experience, a small effect |
Nobody told the model any of these rules. It worked them out from 1,600 examples. That is machine learning in one table.
Why start with logistic regression?
- It is fast and works well on small, table-shaped datasets like this one.
- It is a strong baseline. Professionals always train a simple model first, so they know whether fancier models are actually better.
- It is explainable. As you just saw, you can read its reasoning. In regulated industries such as insurance and lending, that matters as much as accuracy.
The scikit-learn pattern you will use forever
Every model in scikit-learn follows the same three-step pattern. Learn it once and you can swap in any algorithm:
model = SomeModel() # 1. create
model.fit(X_train, y_train) # 2. train
predictions = model.predict(X) # 3. predict
Step 5: Evaluate how good the model is
The model correctly predicts 368 of the 400 unseen orders: 92% accuracy, against 62% for a lazy guess. It raises 15 false alarms and misses 17 late orders.
Accuracy
from sklearn.metrics import accuracy_score
y_pred = model.predict(X_test)
print(f"Accuracy: {accuracy_score(y_test, y_pred):.1%}")
Output:
Accuracy: 92.0%
model.predict(X_test) asks the model about all 400 hidden orders. accuracy_score compares its guesses with what really happened. Accuracy = correct predictions ÷ total predictions, so 368 ÷ 400 = 92%.
Always compare with a lazy baseline
Is 92% good? Imagine a "model" that ignores everything and always says "on time". Because 62% of orders are on time, it would score 62% accuracy while being completely useless: it never warns anyone about a late order.
| Approach | Accuracy | Catches late orders? |
| Always guess "on time" | 62% | Never |
| Our logistic regression | 92% | 89% of them |
Always ask "compared with what?" A score only means something next to a simple baseline.
Confusion matrix: *which* mistakes does it make?
from sklearn.metrics import confusion_matrix
print(confusion_matrix(y_test, y_pred))
Output:
[[233 15]
[ 17 135]]
The same numbers with labels:
| Model said on time | Model said late | |
| Actually on time (248 orders) | 233 ✔ correct | 15 ✖ false alarm |
| Actually late (152 orders) | 17 ✖ missed | 135 ✔ correct |
The two kinds of mistake feel very different to a customer:
- False alarm (15 orders): the app warns "may be late", but the food arrives on time. Mildly annoying; the customer is pleasantly surprised.
- Missed late order (17 orders): the app promises 45 minutes and the food arrives at 55. This is the one that causes angry reviews and refund requests.
In real projects, deciding which mistake is more expensive is a business decision, and it shapes how you tune the model.
Classification report: precision and recall
from sklearn.metrics import classification_report
print(classification_report(y_test, y_pred, target_names=["On time", "Late"]))
Output:
precision recall f1-score support
On time 0.93 0.94 0.94 248
Late 0.90 0.89 0.89 152
accuracy 0.92 400
| Metric | Plain-English question | For "Late" |
| Precision | When the model warns "late", how often is it right? | 90%: 135 of its 150 warnings were real |
| Recall | Of all the orders that really were late, how many did it catch? | 89%: it caught 135 of 152 |
| F1-score | One number balancing precision and recall | 0.89 |
| Support | How many test orders were in this class | 152 |
Tip: if missed late orders are very costly, you can make the model more cautious, for example warning whenever the late probability is above 30% instead of 50%. Recall goes up (fewer misses) and precision goes down (more false alarms). That trade-off appears in every classification problem, from spam filters to fraud detection.
Theory break: how to judge a classification model
Accuracy alone can't tell you whether a model is useful. Precision asks "can I trust its warnings?", recall asks "how many real cases did it catch?", and F1 balances the two. Which one matters most depends on what a mistake costs.
Step 5 showed you the numbers. This section explains where each one comes from, why it matters, and how to choose between them. Every example uses the delivery model's real results.
1. The four possible outcomes
Every prediction a yes/no model makes lands in one of four boxes. We call the class we care about catching ("late") the positive class.
| Outcome | The model said | Reality | Delivery example | Count |
| True positive (TP) | Late | Late | Warned the customer, and the food was late | 135 |
| False positive (FP) | Late | On time | A false alarm: warned, but the food arrived on time | 15 |
| False negative (FN) | On time | Late | A miss: promised on time, food arrived late | 17 |
| True negative (TN) | On time | On time | Correctly said nothing | 233 |
"True/false" says whether the model was right; "positive/negative" says what the model predicted. A false negative is a wrong "no".
The confusion matrix in Step 5 is simply these four counts arranged in a grid:
- Rows are reality. The Actually late row (17 + 135 = 152) is that class's support.
- Columns are the model's predictions. The Model said late column (15 + 135 = 150) is every warning the model gave.
- The diagonal (233 and 135) holds the correct answers; everything else is a mistake.
Every metric below is a different way of combining these four numbers.
2. Accuracy: the share of all predictions that were right
\text{accuracy} = \frac{TP + TN}{\text{all predictions}} = \frac{135 + 233}{400} = 0.92
Accuracy is easy to understand, but it treats every mistake the same and can be badly misleading when one class is rare. This is the accuracy paradox:
- Our lazy "always say on time" model scores 62% while catching zero late orders.
- A fraud model that always says "not fraud" scores 99.9% if only 1 transaction in 1,000 is fraud, and catches nothing.
So use accuracy only when the classes are roughly balanced and both kinds of mistake cost about the same. Otherwise, look further.
3. Precision: can I trust the model's warnings?
\text{precision} = \frac{TP}{TP + FP} = \frac{135}{135 + 15} = \frac{135}{150} = 0.90
Precision looks down the "Model said late" column: of the 150 times the model warned "late", 135 were real. Its denominator is what the model predicted.
Precision matters most when false alarms are costly. For example:
- A spam filter: a false positive sends an important email to the junk folder.
- Auto-rejecting insurance claims as fraudulent: a false positive wrongly denies a genuine customer.
- If the app gave a discount voucher with every "late" warning, every false alarm would cost money.
4. Recall: how many of the real cases did the model catch?
\text{recall} = \frac{TP}{TP + FN} = \frac{135}{135 + 17} = \frac{135}{152} = 0.89
Recall looks across the "Actually late" row: of the 152 orders that really were late, the model caught 135. Its denominator is what really happened (the support). Recall is also called sensitivity or the true positive rate.
Recall matters most when misses are costly. For example:
- Medical screening: a missed disease is far worse than a follow-up test.
- Fraud detection: every missed fraud is money lost.
- Our delivery app: every missed late order is a customer who was promised 45 minutes and waited 55, and that's when angry reviews happen.
Memory trick: precision divides by what the model predicted; recall divides by what really happened.
5. The precision–recall trade-off
You usually can't maximise both. The model actually outputs a probability of "late", and .predict() warns when it's above 0.5. Move that threshold and you trade one metric for the other. Here are the delivery model's real results at three thresholds:
from sklearn.metrics import precision_score, recall_score, f1_score
proba = model.predict_proba(X_test)[:, 1] # chance of "late" for each order
for threshold in [0.3, 0.5, 0.7]:
warn = (proba >= threshold).astype(int)
print(threshold,
round(precision_score(y_test, warn), 3),
round(recall_score(y_test, warn), 3),
round(f1_score(y_test, warn), 3))
| Warn when chance of late is at least… | Warnings given | Late orders caught (of 152) | Missed | False alarms | Precision | Recall | F1 |
| 30% (cautious) | 174 | 144 | 8 | 30 | 0.828 | 0.947 | 0.883 |
| 50% (default) | 150 | 135 | 17 | 15 | 0.900 | 0.888 | 0.894 |
| 70% (strict) | 129 | 121 | 31 | 8 | 0.938 | 0.796 | 0.861 |
- Lower threshold (cautious): warn more often. You catch more late orders (higher recall) but raise more false alarms (lower precision).
- Higher threshold (strict): warn only when very sure. Fewer false alarms (higher precision), but more late orders slip through (lower recall).
No threshold is "correct" in general. The business decides, based on which mistake hurts more. If missed late orders cost you customers, the cautious 30% threshold, which misses only 8 instead of 17, may be worth 15 extra false alarms. Part 5 of the series shows how to choose the threshold systematically.
6. F1-score: one number that balances both
F_1 = 2 \times \frac{\text{precision} \times \text{recall}}{\text{precision} + \text{recall}} = 2 \times \frac{0.900 \times 0.888}{0.900 + 0.888} \approx 0.894
F1 is the harmonic mean of precision and recall. Unlike a normal average, it's dragged down hard by whichever is weaker, so a model can't score well by being great at one and terrible at the other:
| Precision | Recall | Normal average | F1 |
| 0.90 | 0.89 | 0.895 | 0.894 |
| 1.00 | 0.10 | 0.55 | 0.18 |
The second model sounds fine by its average, but it catches only 1 late order in 10. F1 exposes it. Use F1 when you need a single number to compare models and both kinds of mistake matter, especially with imbalanced classes. Notice in the threshold table that F1 peaks at the default threshold: it rewards the best balance, not the best of either metric.
7. Reading the whole classification report
precision recall f1-score support
On time 0.932 0.940 0.936 248
Late 0.900 0.888 0.894 152
accuracy 0.920 400
macro avg 0.916 0.914 0.915 400
weighted avg 0.920 0.920 0.920 400
(Add digits=3 to classification_report to see three decimal places, as here.)
- One row per class. Each row treats that class as the positive one. The "On time" row's precision is 233 ÷ (233 + 17) = 233 ÷ 250 = 0.932: the "Model said on time" column. Its recall is 233 ÷ 248 = 0.940: the "Actually on time" row.
- Support is how many test orders truly belong to each class, so it's always a row total of the confusion matrix, never a column total.
- Accuracy appears once, because it covers all classes together.
- Macro avg is the plain average of the two rows: (0.900 + 0.932) ÷ 2 = 0.916 for precision. Every class counts equally, so it highlights how well you handle the smaller class.
- Weighted avg weights each row by its support, so the bigger "On time" class counts more. It usually sits close to accuracy.
When classes are imbalanced, look at the row for the class you care about (here, "Late") and the macro average, not just accuracy or the weighted average.
8. Which metric should you use?
| Situation | Focus on | Why |
| Balanced classes, mistakes cost about the same | Accuracy | Simple and fair in this case |
| False alarms are expensive (spam folder, wrongly blocking a payment) | Precision | Keep warnings trustworthy |
| Misses are expensive (disease, fraud, a customer left waiting) | Recall | Catch as many real cases as possible |
| Rare positive class, both mistakes matter | F1 (for the positive class) | Balances both; can't be gamed by ignoring the rare class |
| Comparing models on imbalanced data | F1 or macro average, plus the confusion matrix | Accuracy hides the minority class |
Whatever you report, always look at the confusion matrix too. Every metric is a summary of it, and the raw counts ("17 late orders missed out of 400") are what managers and customers actually understand.
Quick self-check
- A model predicts "late" for every single order. What are its recall and its precision for "late"?
- Why is the "Late" support 152 but the model's number of "late" warnings 150?
- Your manager says, "A customer who isn't warned and gets food late is five times worse than a false alarm." Which threshold from the table would you suggest, and why?
Answers: (1) Recall is 1.0, because it catches all 152, but precision is 152 ÷ 400 = 0.38, because most warnings are false alarms. A perfect score on one metric can hide a useless model. (2) Support counts orders that really were late (a row); warnings count what the model predicted (a column). The model made 15 false alarms but 17 misses, so the totals differ by 2. (3) The 30% threshold: it cuts misses from 17 to 8 at the cost of 15 extra false alarms. With misses five times worse, that trade is clearly worth it.
Step 6: Try other models and learn when fancier is not better
We test five more models. None beats plain logistic regression (92%), and a decision tree that scores 100% on training data drops to 88% on new orders. That is overfitting, caught in the act.
Because every scikit-learn model uses the same .fit() / .predict() pattern, comparing them is a simple loop:
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
models = {
"Logistic regression": LogisticRegression(max_iter=1000),
"Logistic regression + scaling": make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)),
"Decision tree (depth 3)": DecisionTreeClassifier(max_depth=3, random_state=42),
"Decision tree (no limit)": DecisionTreeClassifier(random_state=42),
"K-nearest neighbours": KNeighborsClassifier(),
"Random forest": RandomForestClassifier(n_estimators=200, random_state=42),
}
for name, m in models.items():
m.fit(X_train, y_train)
train_acc = accuracy_score(y_train, m.predict(X_train))
test_acc = accuracy_score(y_test, m.predict(X_test))
cv_acc = cross_val_score(m, X_train, y_train, cv=5).mean()
print(f"{name:30s} train={train_acc:.3f} test={test_acc:.3f} cv={cv_acc:.3f}")
Output:
Logistic regression train=0.912 test=0.920 cv=0.914
Logistic regression + scaling train=0.912 test=0.920 cv=0.912
Decision tree (depth 3) train=0.865 test=0.850 cv=0.843
Decision tree (no limit) train=1.000 test=0.877 cv=0.856
K-nearest neighbours train=0.897 test=0.845 cv=0.849
Random forest train=1.000 test=0.910 cv=0.893
The results, explained
| Model | Training accuracy | Test accuracy | How it works, in one line |
| Logistic regression | 91% | 92% | Adds up weighted facts, like a points system |
| Random forest | 100% | 91% | 200 decision trees vote on the answer |
| Decision tree (no limit) | 100% | 88% | One giant flowchart of yes/no questions |
| Decision tree (depth 3) | 87% | 85% | A flowchart only 3 questions deep |
| K-nearest neighbours | 90% | 85% | Finds the 5 most similar past orders and takes a vote |
Lesson 1: the simplest model won, and that's normal
Our hidden recipe is basically a points system: each kilometer, each minute and the rain all add to the delivery time. Logistic regression is a points system, so it fits the problem naturally. Complex models are not automatically better; they win when the pattern is genuinely complex (lots of interactions, images, text). Always let the test score decide.
Lesson 2: spot overfitting by comparing training and test scores
The unlimited decision tree scored a perfect 100% on training orders but only 88% on new ones. It memorized the quirks and random luck of the 1,600 training orders, like a student who memorised last year's exam answers. That gap between training and test scores is the classic sign of overfitting.
The random forest also scores 100% on training data, but averaging 200 trees keeps its test score high (91%). Logistic regression shows almost no gap (91% vs 92%), which means it learned general patterns rather than memorizing.
Lesson 3: some models explain themselves
The small depth-3 tree is less accurate, but you can print it as rules a human can read:
from sklearn.tree import export_text
print(export_text(models["Decision tree (depth 3)"], feature_names=list(X.columns)))
In plain English, the tree learned:
- Within about 7 km: usually on time, unless the kitchen takes more than 30 minutes and the order is over 3.75 km away.
- Beyond about 7 km: usually late, unless the kitchen is fast (18 minutes or less) and it's not raining.
Any delivery rider would nod at those rules. That is the appeal of simple, explainable models.
What is cross-validation (the `cv` column)?
One test split can be lucky or unlucky. Cross-validation (cv=5) splits the training data into 5 parts, trains on 4 and tests on the 5th, five times over, then averages the scores. It gives a more trustworthy estimate without touching the real test set. Here it agrees with the test scores, with logistic regression on top at 91%.
Why didn't scaling help this time?
Scaling puts all features on a similar range (distance runs 0.5–12, experience 0–48). It often helps logistic regression, SVMs and k-nearest neighbors. Here logistic regression already converged fine, so scaling changed nothing. A pipeline is still good practice: it learns the scaling from training data only, so nothing leaks from the test set.
Step 7: Predict new orders and save your model
Ask the model about a brand-new order: 6 km, rain, dinner rush. It says 85% chance of being late. Remove the rain and it drops to 21%; move it off-peak and it falls to 4%.
Predict a brand-new order
New data must have the same columns, in the same order, as the training data. The easiest way is a one-row DataFrame:
new_order = pd.DataFrame([{
"distance_km": 6.0,
"prep_time_min": 18,
"is_raining": 1,
"is_peak_hour": 1,
"rider_experience_months": 6,
}])
print(model.predict(new_order)) # the yes/no answer
print(model.predict_proba(new_order)[0, 1].round(2)) # the chance it is late
Output:
[1]
0.85
predict gives the decision (1 = late). predict_proba gives the probability, which is often more useful: an app could show "likely late" above 70% and "slight delay possible" between 40% and 70%.
Play "what if?"
Change one fact at a time and watch the probability move:
what_if = pd.DataFrame([
{"distance_km": 6.0, "prep_time_min": 18, "is_raining": 1, "is_peak_hour": 1, "rider_experience_months": 6},
{"distance_km": 6.0, "prep_time_min": 18, "is_raining": 0, "is_peak_hour": 1, "rider_experience_months": 6},
{"distance_km": 6.0, "prep_time_min": 18, "is_raining": 0, "is_peak_hour": 0, "rider_experience_months": 6},
])
print(model.predict_proba(what_if)[:, 1].round(2))
[0.85 0.21 0.04]
| Scenario | Chance of being late |
| 6 km, raining, dinner rush | 85% |
| Same order, no rain | 21% |
| Same order, no rain, off-peak | 4% |
This is how a delivery company might decide when to add a "rain surcharge", send more riders, or quietly stretch the time estimate. Try your own scenarios: a 2 km order with a 35-minute kitchen, or a 12 km order with a veteran rider.
Save the model to a file
import joblib
joblib.dump(model, "late_delivery_model.joblib")
You now have a file holding everything the model learned. No retraining needed.
Load it later and use it
In a new script, a web app or an API:
import joblib
model = joblib.load("late_delivery_model.joblib")
print(model.predict_proba(what_if)[:, 1].round(2))
[0.85 0.21 0.04]
Same answers: the saved model is identical to the one you trained.
Where this goes next in a real project
- In the app: call the model when an order is placed and adjust the promised time or show a delay warning.
- Operations dashboard: flag high-risk orders so a dispatcher can assign the fastest rider.
- Retraining: as new orders come in, rerun Steps 2–7 regularly so the model keeps up with new restaurants, roads and seasons.
Security note: only load .joblib or pickle files that you created or fully trust. Loading a file can run code hidden inside it. Also load the model with the same scikit-learn version you saved it with, or you may see warnings or errors.
The complete script
Here is the whole project in one file. Save it as **first_ml_model.py** and run **python first_ml_model.py**.
"""Your first machine learning model: predict whether a food delivery will be late."""
import joblib
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from sklearn.model_selection import train_test_split
# 1. Create the dataset: 2,000 simulated food delivery orders
rng = np.random.default_rng(42)
n = 2000
orders = pd.DataFrame({
"distance_km": rng.uniform(0.5, 12, n).round(1),
"prep_time_min": rng.integers(5, 36, n),
"is_raining": (rng.random(n) < 0.2).astype(int),
"is_peak_hour": (rng.random(n) < 0.35).astype(int),
"rider_experience_months": rng.integers(0, 49, n),
})
travel = orders.distance_km * 3 * (1 + 0.5 * orders.is_raining) * (1 + 0.3 * orders.is_peak_hour)
total = orders.prep_time_min + travel - 0.1 * orders.rider_experience_months + rng.normal(0, 5, n)
orders["is_late"] = (total > 45).astype(int) # promised delivery: 45 minutes
# 2. Explore
print(orders.head())
print("Late orders:", f"{orders.is_late.mean():.0%}")
# 3. Split into training and test sets
X = orders.drop(columns="is_late")
y = orders["is_late"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
# 4. Train the model
model = LogisticRegression(max_iter=1000)
model.fit(X_train, y_train)
# 5. Evaluate on unseen orders
y_pred = model.predict(X_test)
print(f"Accuracy: {accuracy_score(y_test, y_pred):.1%}")
print(confusion_matrix(y_test, y_pred))
print(classification_report(y_test, y_pred, target_names=["On time", "Late"]))
# 6. Compare with a random forest
forest = RandomForestClassifier(n_estimators=200, random_state=42)
forest.fit(X_train, y_train)
print(f"Random forest accuracy: {accuracy_score(y_test, forest.predict(X_test)):.1%}")
# 7. Save, reload and predict a brand-new order
joblib.dump(model, "late_delivery_model.joblib")
loaded = joblib.load("late_delivery_model.joblib")
new_order = pd.DataFrame([{
"distance_km": 6.0, "prep_time_min": 18, "is_raining": 1,
"is_peak_hour": 1, "rider_experience_months": 6,
}])
chance = loaded.predict_proba(new_order)[0, 1]
print(f"Chance this order is late: {chance:.0%}")
Expected output (key lines):
Late orders: 38%
Accuracy: 92.0%
...
Random forest accuracy: 91.0%
Chance this order is late: 85%
In a terminal, orders.head() may hide middle columns with ...; in Jupyter or Colab you see the full table.
Common mistakes and how to fix them
| Error or symptom | Cause | Fix |
ModuleNotFoundError: No module named 'sklearn' | Library not installed, or virtual environment not active | Activate venv, then pip install scikit-learn pandas. Note: you install scikit-learn but import sklearn. |
ValueError: Expected 2D array, got 1D array instead | Predicting with a single flat list such as [6.0, 18, 1, 1, 6] | Wrap it as a one-row table: a one-row DataFrame (as in Step 7) or [[6.0, 18, 1, 1, 6]] |
UserWarning: X does not have valid feature names | Trained on a DataFrame, predicted with a plain list or array | Predict with a DataFrame that has the same column names |
KeyError: 'is_late' | Typo in a column name | Check spelling with print(orders.columns) |
ConvergenceWarning: lbfgs failed to converge | Logistic regression ran out of iterations | Add max_iter=1000, or scale the data with StandardScaler |
| Accuracy near 100% on a hard problem | The answer leaked into the inputs, e.g. is_late left inside X | Make sure X = orders.drop(columns="is_late"); split before any scaling |
| Results change every run | Random seed not fixed | Set random_state=42 and default_rng(42) |
| Great training score, poor test score | Overfitting | Use a simpler model, more data, or limit tree depth |
| Wrong variables in the wrong place | Mixed up the split's return order | It is always X_train, X_test, y_train, y_test |
Beginner habits worth building now: look at the data first, sanity-check results against common sense, always keep a test set aside, compare against a lazy baseline, start simple, and change one thing at a time.
Practice project: will this home go over its electricity budget?
Now it's your turn. Use everything from this guide on a completely different problem: predicting whether a home's monthly electricity use will exceed its 500-unit budget. Same steps, new data, and this time you write the code.
The practice project runs through the whole Delivery ML series. In Part 2 you'll predict the exact units, and in Part 3 you'll clean a messy smart-meter export, so keep your notebook.
The scenario
An energy company wants to warn customers before the month ends if they're likely to go over 500 units (kWh), the point where many tariffs jump to a higher price. For 1,500 homes you have:
| Column | Meaning |
home_size_sqm | Floor area in square metres |
residents | People living in the home (1–6) |
is_summer | 1 if the reading is from the summer months |
has_solar | 1 if the home has solar panels |
works_from_home | 1 if someone works from home |
appliance_age_years | Average age of the main appliances |
ac_hours_per_day | Average daily air-conditioner use |
monthly_kwh | Units used this month (we'll turn this into the label) |
Get the data
Copy this function into a cell (or save it as energy_data.py). Like the delivery data, it uses a hidden recipe plus random luck, so everyone gets identical homes.
import numpy as np
import pandas as pd
def make_homes(n=1500, seed=11):
rng = np.random.default_rng(seed)
homes = pd.DataFrame({
"home_size_sqm": rng.uniform(30, 200, n).round(),
"residents": rng.integers(1, 7, n),
"is_summer": (rng.random(n) < 0.4).astype(int),
"has_solar": (rng.random(n) < 0.25).astype(int),
"works_from_home": (rng.random(n) < 0.3).astype(int),
"appliance_age_years": rng.integers(0, 16, n),
})
ac_hours = np.where(homes.is_summer == 1, rng.normal(6, 2.5, n), rng.normal(1, 1, n))
homes["ac_hours_per_day"] = ac_hours.clip(0, 14).round(1)
# Hidden recipe for monthly units (kWh)
kwh = (50
+ 0.9 * homes.home_size_sqm
+ 35 * homes.residents
+ 45 * homes.ac_hours_per_day * (homes.home_size_sqm / 100) # AC costs more in bigger homes
+ 4 * homes.appliance_age_years
+ 60 * homes.works_from_home)
kwh = kwh * (1 - 0.35 * homes.has_solar) + rng.normal(0, 30, n)
homes["monthly_kwh"] = kwh.clip(lower=20).round()
return homes
homes = make_homes()
homes["over_budget"] = (homes["monthly_kwh"] > 500).astype(int) # the label
Your tasks
Work through these in order. Each one matches a step in this guide; look back at that step if you get stuck.
- 1. Explore (Step 2). Show the first rows. What share of homes are over budget? Compare the over-budget rate in summer vs not summer, and with vs without solar panels.
- 2. Choose the features (theory break). Which column is the label? One column must not be used as a feature, because it would give away the answer. Which one, and why? (Hint: it's a case of data leakage.)
- 3. Split and set a baseline (Steps 3 and 5). Split 80/20 with
random_state=42andstratify=y. What accuracy would "always guess not over budget" score on the test set? - 4. Train and evaluate (Steps 4 and 5). Train
LogisticRegression(max_iter=1000). Report accuracy and the confusion matrix. How many over-budget homes did it miss? - 5. Read the model (Step 4). Print the coefficients. Which feature pushes hardest towards over budget, and which pushes hardest *against*? Do they make common sense?
- 6. Compare models (Step 6). Try an unlimited
DecisionTreeClassifierand aRandomForestClassifier(n_estimators=200). Which overfits? Which wins on the test set? Is it the same winner as in the delivery problem? - 7. Play "what if?" (Step 7). For a 120 m² home with 4 residents, in summer, someone working from home, 8-year-old appliances and 8 hours of AC a day: what's the chance of going over budget without solar, and with solar?
- 8. Save your model (Step 7) with joblib.
Check your results
Try the tasks first, then compare. Your numbers should match exactly if you used the same seeds and settings.
| Task | Expected result |
| 1. Over-budget share | 30.1% of homes |
| 1. Summer vs not summer | 58% vs 12% over budget |
| 1. Solar vs no solar | 9% vs 36% over budget |
| 2. Column to exclude | monthly_kwh: the label is calculated from it, so including it is leakage. Drop both monthly_kwh and over_budget from X. |
| 3. Baseline accuracy | 70.0% |
| 4. Logistic regression | 95.3% accuracy; confusion matrix [[203 7] [7 83]], so 7 over-budget homes missed |
| 5. Strongest coefficients | has_solar −4.98 (strongest, against); works_from_home +1.86 (strongest towards) |
| 6. Decision tree | 100% on training data, 93.3% on test: overfitting |
| 6. Random forest | 96.7%: here it beats logistic regression, unlike in the delivery problem |
| 7. What if | 100% without solar, 89% with solar (both shown rounded) |
Think about it: in the delivery problem the simple model won; here the random forest wins. Why might that be? (Hint: look at the hidden recipe. How does AC use combine with home size? We'll use that idea in Part 2.)
Post your notebook and your answer to the "think about it" question in the comments. Explaining why is the best way to check you've understood.
Frequently asked questions
How do I build my first machine learning model in Python?
Install scikit-learn, load a dataset, split it into training and test sets with train_test_split, create a model such as LogisticRegression(), train it with .fit(), and check accuracy on the test set. The whole process takes about 30 lines of code.
Do I need to know maths to build a machine learning model?
No. Libraries like scikit-learn handle the maths for you. Understanding percentages and averages is enough to start. Learning some statistics and linear algebra later will help you choose and tune models better.
What is the best Python library for beginners in machine learning?
scikit-learn. It has built-in datasets, dozens of algorithms behind one consistent .fit() / .predict() interface, and excellent documentation. Move to PyTorch or TensorFlow when you start deep learning.
Why do we split data into training and test sets?
To measure how well the model handles data it has never seen. Testing on training data rewards memorisation, so the score looks better than real-world performance will be.
What is a good accuracy for a machine learning model?
It depends on the problem and the baseline. In this guide, always guessing "on time" scores 62%, so our 92% is a big improvement. For rare events such as fraud, accuracy can mislead; use precision and recall instead.
Do I need a GPU to train machine learning models?
Not for classic machine learning. Every model in this guide trains in seconds on an ordinary laptop. GPUs matter for deep learning on large image, audio or text datasets.
What should I build after this tutorial?
Continue with Part 2 of the Delivery ML series, Predict the Exact Delivery Time. It reuses the same orders and introduces regression. The full eight-part roadmap is in the "What to learn next" section below.
Glossary
| Term | Plain-English meaning |
| Dataset | The collection of examples you learn from |
| Feature | One input fact, here distance, prep time, rain, peak hour or rider experience |
| Label / target | The correct answer for an example, here whether the order was late (1) or on time (0) |
| Training set | The examples the model learns from |
| Test set | Held-back examples used only for the final check |
| Model | The trained program that makes predictions |
| Fit | Train a model on data |
| Predict | Use a trained model to guess answers for new inputs |
| Classification | Predicting a category (late or on time, spam or not) |
| Regression | Predicting a number (a price, a temperature) |
| Accuracy | Share of predictions that were correct |
| Precision / recall | How trustworthy a positive guess is / how many real cases were found |
| Cross-validation | Averaging scores over several train/test rotations for a fairer estimate |
| Overfitting | Memorising the training data instead of learning general patterns |
| Pipeline | A chain of steps (such as scaling, then a model) run as one |
What to learn next: the Delivery ML series
This guide is Part 1 of an 8-part series. Every part uses the same food delivery orders and starts exactly where the previous part ended, so you learn one new skill at a time on a problem you already understand.
| Part | Tutorial | What you'll learn | How it builds on the previous part |
| 1 | How to Build Your First Machine Learning Model (you are here) | Classification, train/test split, accuracy, confusion matrix, overfitting | — |
| 2 | Predict the Exact Delivery Time | Regression, error in minutes (MAE, RMSE), regression vs classification | Same 2,000 orders, but instead of "late or not" you predict "47 minutes" |
| 3 | Clean Messy, Real-World Delivery Data | Missing values, categories (cuisine, city zone), outliers, timestamps, pipelines | The dataset grows up: raw order times, gaps and typos. You turn the feature theory from Part 1 into real feature engineering. |
| 4 | Deploy Your Model as an API | FastAPI, Docker, calling a model from an app | Serves the cleaned-data model from Part 3 so an app can show "may be late" when an order is placed |
| 5 | Tune Your Model | Hyperparameters, GridSearchCV, choosing the right decision threshold | Takes up Part 1's "warn above 30% instead of 50%" tip and does it properly, then redeploys the tuned model to the Part 4 API |
| 6 | Explain Your Model's Predictions | Feature importance, SHAP values, fairness checks | Answers "why was this order flagged?" and checks whether new riders are treated unfairly |
| 7 | When Simple Isn't Enough: Gradient Boosting | XGBoost/LightGBM, feature interactions | The hidden recipe changes so rain and distance interact; logistic regression starts losing, and you see why |
| 8 | Add Generative AI: Explain Delays to Customers | Calling an LLM API, prompting, guardrails | Turns the model's output (85%, rain, rush hour) into a friendly customer message, connecting classic ML with generative AI |
How the series fits together
- One problem, growing in realism: Parts 1–2 use the clean simulated orders from this guide; from Part 3 onwards the data gets messier and closer to what delivery companies really store.
- One code repository: each part has its own folder and notebook, and starts by loading what the previous part saved.
- One skill per part: if a part feels hard, the previous part covers everything it assumes. Each part's Prerequisites section names exactly which earlier parts you need.
Next up: Part 2, Predict the Exact Delivery Time. Keep first_ml_model.py handy: you will reuse its dataset code line for line.
Conclusion
You have built, evaluated, improved and saved a real machine learning model, and every ML project follows the same seven steps: load, explore, split, train, evaluate, improve and deploy.
The algorithms get more sophisticated as you go, but the discipline stays the same: understand your data, sanity-check against common sense, keep a fair test set, beat a simple baseline, and let the measurements decide. Change one thing in the script today, perhaps the 45-minute promise or the share of rainy orders, and watch what the model learns. That curiosity is how machine learning engineers are made.
Sources
No sources recorded.
Keep exploring
View all →Basics of AI/ML
Build a Neural Network Without PyTorch. What Changes When It Learns?
Basics of AI/ML
How to Use XGBoost and LightGBM in Python: A Beginner's Guide to Gradient Boosting (Food Delivery ML Series, Part 7)
Basics of AI/ML
How to Explain Machine Learning Predictions in Python: A Beginner's Guide to Feature Importance and SHAP (Delivery ML Series, Part 6)
