Final AI Project

Reviewed & published by Brayan K

In this capstone you'll build one complete machine learning project end-to-end — loading data, training a model from scratch, evaluating it, and making predictions — so you finish able to ship real AI work.

Part of the free AI & Machine Learning course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1 The Project: A Fruit Classifier, Built Across 5 Milestones

You'll build one project the whole way through: a classifier that decides whether a fruit is an apple or an orange from two numbers — its weight and how bumpy its skin is. It's deliberately tiny so the data fits on screen and you can check every number by hand. The workflow, though, is identical to a million-row production system.

The five milestones — the universal ML workflow:

All code below is plain Python — no numpy, no scikit-learn — so nothing is hidden. At the end you'll see the same project in scikit-learn to connect what you built to the tools the industry uses.

2 Milestone 1 — Load & Inspect the Dataset

Every project starts by getting data into a shape you can work with and looking at it. Here the dataset is a list of dictionaries — one dict per fruit. Before modelling anything, you check how many samples there are and whether the classes are balanced. A lopsided dataset (say 95% apples) changes every decision that follows.

Run it and read the comments — the # Expected output at the bottom tells you exactly what you should see.

# ============================================
# MILESTONE 1: LOAD & INSPECT THE DATASET
# ============================================
# A tiny fruit dataset. Each row = one fruit.
# Two features: weight (grams) and bumpiness (0 = smooth, 10 = very bumpy).
# Label: "apple" or "orange".

dataset = [
    {"weight": 150, "bumpiness": 2, "label": "apple"},
    {"weight": 170, "bumpiness": 3, "label": "apple"},
    {"weight": 140, "bumpiness": 1, "label": "apple"},
    {"weight": 130, "bumpiness": 2, "label": "apple"},
    {"weight": 180, "bumpiness": 7, "label": "orange"},
    {"weight": 200, "bumpiness": 8, "label": "orange"},
    {"weight": 190, "bumpiness": 9, "label": "orange"},
    {"weight": 210, "bumpiness": 7, "label": "orange"},
]

# Inspect: how many rows, and how many of each label?
print("Total samples:", len(dataset))

counts = {}
for row in dataset:
    label = row["label"]
    counts[label] = counts.get(label, 0) + 1
print("Class counts:", counts)

# Peek at the first 3 rows (like df.head() in pandas)
print("First 3 rows:")
for row in dataset[:3]:
    print("  ", row)

# Expected output:
# Total samples: 8
# Class counts: {'apple': 4, 'orange': 4}
# First 3 rows:
#    {'weight': 150, 'bumpiness': 2, 'label': 'apple'}
#    {'weight': 170, 'bumpiness': 3, 'label': 'apple'}
#    {'weight': 140, 'bumpiness': 1, 'label': 'apple'}

3 Milestone 2 — Preprocess & Split

Models work on numbers, so first you encode the text labels (apple → 0, orange → 1). Then you scale the features to a 0-1 range — without this, weight (around 200) would completely drown out bumpiness (around 10) and the model would barely notice the bumpiness at all.

Finally you split the data: most rows train the model, a few are held back to test it. The golden rule of ML is that you never test on data the model trained on — that's like giving students the exam answers in advance.

# ============================================
# MILESTONE 2: PREPROCESS + TRAIN/TEST SPLIT
# ============================================
dataset = [
    {"weight": 150, "bumpiness": 2, "label": "apple"},
    {"weight": 170, "bumpiness": 3, "label": "apple"},
    {"weight": 140, "bumpiness": 1, "label": "apple"},
    {"weight": 130, "bumpiness": 2, "label": "apple"},
    {"weight": 180, "bumpiness": 7, "label": "orange"},
    {"weight": 200, "bumpiness": 8, "label": "orange"},
    {"weight": 190, "bumpiness": 9, "label": "orange"},
    {"weight": 210, "bumpiness": 7, "label": "orange"},
]

# 1) Encode labels as numbers: apple -> 0, orange -> 1
def encode(label):
    return 0 if label == "apple" else 1

# 2) Scale features to roughly 0-1 so weight (~200) does not
#    dominate bumpiness (~10). This is "min-max normalisation".
weights = [r["weight"] for r in dataset]
bumps = [r["bumpiness"] for r in dataset]
w_min, w_max = min(weights), max(weights)
b_min, b_max = min(bumps), max(bumps)

def scale(row):
    w = (row["weight"] - w_min) / (w_max - w_min)
    b = (row["bumpiness"] - b_min) / (b_max - b_min)
    return [w, b]

# Build feature rows X and label list y
X = [scale(r) for r in dataset]
y = [encode(r["label"]) for r in dataset]

# 3) Train/test split. Keep it deterministic: take 1 apple + 1
#    orange out for testing so the test set has both classes.
test_index = [3, 7]                       # one apple, one orange
train_index = [i for i in range(len(X)) if i not in test_index]

X_train = [X[i] for i in train_index]
y_train = [y[i] for i in train_index]
X_test = [X[i] for i in test_index]
y_test = [y[i] for i in test_index]

print("Train size:", len(X_train), "Test size:", len(X_test))
print("First scaled train row:", [round(v, 2) for v in X_train[0]])
print("y_train:", y_train)
print("y_test: ", y_test)

# Expected output:
# Train size: 6 Test size: 2
# First scaled train row: [0.25, 0.12]
# y_train: [0, 0, 0, 1, 1, 1]
# y_test:  [0, 1]

4 Milestone 3 — Train a Perceptron From Scratch

A perceptron is the simplest learning unit there is — the great-grandparent of every neural network. It multiplies each feature by a weight, adds a bias, and fires 1 if the total is positive, else 0.

"Training" just means: for each example, make a guess, and if it's wrong, nudge the weights a little in the direction that would have been right. Repeat over the whole dataset a few times (each pass is an epoch) until it stops making mistakes. That single line — weights[i] += learning_rate * error * features[i] — is the entire learning algorithm.

# ============================================
# MILESTONE 3: TRAIN A PERCEPTRON FROM SCRATCH
# ============================================
# A perceptron is the simplest neural unit: it multiplies each
# feature by a weight, adds a bias, and fires 1 if the total is
# positive, else 0. Training nudges the weights toward correct answers.

# Scaled training data from Milestone 2 (apple=0, orange=1)
X_train = [
    [0.25, 0.12], [0.50, 0.25], [0.12, 0.00],   # apples
    [0.62, 0.75], [0.88, 0.88], [0.75, 1.00],   # oranges
]
y_train = [0, 0, 0, 1, 1, 1]

# Start with zero weights and bias
weights = [0.0, 0.0]
bias = 0.0
learning_rate = 0.1

def predict(features, weights, bias):
    total = bias
    for i in range(len(features)):
        total += features[i] * weights[i]
    return 1 if total >= 0 else 0   # step activation

# Train for several passes over the data (epochs)
for epoch in range(20):
    errors = 0
    for features, target in zip(X_train, y_train):
        guess = predict(features, weights, bias)
        error = target - guess            # 0 if correct, +1 or -1 if wrong
        if error != 0:
            errors += 1
            # Perceptron update rule: move weights toward the right answer
            for i in range(len(weights)):
                weights[i] += learning_rate * error * features[i]
            bias += learning_rate * error
    if errors == 0:
        print("Converged at epoch", epoch)
        break

print("Final weights:", [round(w, 3) for w in weights])
print("Final bias:", round(bias, 3))

# Expected output:
# Converged at epoch 2
# Final weights: [0.087, 0.15]
# Final bias: -0.1

5 Milestone 4 — Evaluate the Model

A trained model is worthless until you know how good it is on data it has never seen. You run it on the held-out test set and count four outcomes into a confusion matrix:

From those four numbers you compute accuracy (overall correctness), precision (when it says orange, how often it's right), and recall (of all the real oranges, how many it caught).

# ============================================
# MILESTONE 4: EVALUATE THE MODEL
# ============================================
# Trained perceptron from Milestone 3 (orange is the positive class)
weights = [0.087, 0.15]
bias = -0.1

# Held-out test set from Milestone 2 (apple=0, orange=1)
X_test = [[0.00, 0.25], [1.00, 0.75]]   # one apple, one orange
y_test = [0, 1]

def predict(features, weights, bias):
    total = bias
    for i in range(len(features)):
        total += features[i] * weights[i]
    return 1 if total >= 0 else 0

# Collect predictions, then build a confusion matrix.
# Positive class = orange (1).
tp = fp = tn = fn = 0
for features, actual in zip(X_test, y_test):
    pred = predict(features, weights, bias)
    if pred == 1 and actual == 1:
        tp += 1                 # caught an orange
    elif pred == 1 and actual == 0:
        fp += 1                 # false alarm
    elif pred == 0 and actual == 0:
        tn += 1                 # correct apple
    else:
        fn += 1                 # missed an orange

accuracy = (tp + tn) / len(y_test)
precision = tp / (tp + fp) if (tp + fp) else 0
recall = tp / (tp + fn) if (tp + fn) else 0

print("Confusion matrix (positive = orange):")
print("  TP:", tp, " FP:", fp, " TN:", tn, " FN:", fn)
print("Accuracy: ", round(accuracy, 2))
print("Precision:", round(precision, 2))
print("Recall:   ", round(recall, 2))

# Expected output:
# Confusion matrix (positive = orange):
#   TP: 1  FP: 0  TN: 1  FN: 0
# Accuracy:  1.0
# Precision: 1.0
# Recall:    1.0

6 Milestone 5 — Predict on New Data

This is the payoff: feeding the model fruit it has never seen and getting a label back. The one trick beginners miss is that new data must be scaled with exactly the same min/max you used in training — re-fitting the scaler on new data would shift everything and corrupt the predictions.

Notice the third fruit (160g, bumpiness 6) sits near the boundary — a great reminder that real models output a decision even when the answer is genuinely uncertain.

# ============================================
# MILESTONE 5: PREDICT ON NEW, UNSEEN FRUIT
# ============================================
weights = [0.087, 0.15]
bias = -0.1

# The SAME scaling we fit in Milestone 2 (do not re-fit on new data!)
w_min, w_max = 130, 210
b_min, b_max = 1, 9

def scale(weight, bumpiness):
    w = (weight - w_min) / (w_max - w_min)
    b = (bumpiness - b_min) / (b_max - b_min)
    return [w, b]

def classify(weight, bumpiness):
    features = scale(weight, bumpiness)
    total = bias
    for i in range(len(features)):
        total += features[i] * weights[i]
    return "orange" if total >= 0 else "apple"

# Three new fruits the model has never seen
new_fruits = [
    {"weight": 145, "bumpiness": 2},   # light + smooth -> apple
    {"weight": 205, "bumpiness": 8},   # heavy + bumpy  -> orange
    {"weight": 160, "bumpiness": 6},   # in between
]

for fruit in new_fruits:
    label = classify(fruit["weight"], fruit["bumpiness"])
    print(f"weight={fruit['weight']}g bumpiness={fruit['bumpiness']} -> {label}")

# Expected output:
# weight=145g bumpiness=2 -> apple
# weight=205g bumpiness=8 -> orange
# weight=160g bumpiness=6 -> orange

🏭 The Industry Version (scikit-learn) — Read-Only

You just built every piece by hand. In a real job you'd reach for scikit-learn, which collapses all five milestones into a handful of lines. Read this and match each call to the milestone it replaces — MinMaxScaler is Milestone 2, Perceptron().fit() is Milestone 3, the metric functions are Milestone 4, and .predict() is Milestone 5.

# ============================================
# THE SAME PROJECT, THE PROFESSIONAL WAY (scikit-learn)
# Read-only: this is what your from-scratch code maps to in industry.
# ============================================
from sklearn.linear_model import Perceptron
from sklearn.preprocessing import MinMaxScaler
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, precision_score, recall_score

# Features [weight, bumpiness] and labels (apple=0, orange=1)
X = [[150, 2], [170, 3], [140, 1], [130, 2],
     [180, 7], [200, 8], [190, 9], [210, 7]]
y = [0, 0, 0, 0, 1, 1, 1, 1]

# Split, scale, train — the four lines that replace ~80 of ours
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=0)
scaler = MinMaxScaler().fit(X_tr)
model = Perceptron().fit(scaler.transform(X_tr), y_tr)

# Evaluate
pred = model.predict(scaler.transform(X_te))
print("Accuracy: ", round(accuracy_score(y_te, pred), 2))
print("Precision:", round(precision_score(y_te, pred, zero_division=0), 2))
print("Recall:   ", round(recall_score(y_te, pred, zero_division=0), 2))

# Predict a brand-new fruit
print("New fruit ->", model.predict(scaler.transform([[205, 8]]))[0])

# Expected output:
# Accuracy:  1.0
# Precision: 1.0
# Recall:    1.0
# New fruit -> 1

Same result, a fraction of the code. The point of building it from scratch first is that none of this is a black box to you anymore — you know precisely what each line is doing.

🎯 Your Turn 1 — Build a k-NN Classifier

You've built a perceptron. Now build the other classic classifier — k-Nearest Neighbours — by filling in two blanks. k-NN classifies a point by letting its closest labelled neighbours vote. Fill in the squared difference in the distance function and the majority threshold.

# 🎯 YOUR TURN 1 — k-NN: measure how "close" two fruits are
# k-Nearest Neighbours classifies a point by looking at the labelled
# points nearest to it. The engine is a distance function. Fill it in.

X_train = [[0.25, 0.12], [0.50, 0.25], [0.12, 0.00],
           [0.62, 0.75], [0.88, 0.88], [0.75, 1.00]]
y_train = [0, 0, 0, 1, 1, 1]   # apple=0, orange=1

def distance(a, b):
    total = 0
    for i in range(len(a)):
        diff = a[i] - b[i]
        total += ___          # 👉 add the SQUARED difference (diff * diff)
    return total ** 0.5       # square root = Euclidean distance

def knn_predict(point, k=3):
    # Pair every training point with its distance to 'point'
    pairs = [(distance(point, X_train[i]), y_train[i]) for i in range(len(X_train))]
    pairs.sort()                       # nearest first
    top = pairs[:k]                    # k closest neighbours
    votes = sum(label for _, label in top)
    return 1 if votes > ___ else 0     # 👉 majority of k=3 means more than 1 vote

# A mystery fruit, already scaled
mystery = [0.80, 0.90]                 # heavy + bumpy
print("k-NN says:", "orange" if knn_predict(mystery) == 1 else "apple")

# ✅ Expected output:
# k-NN says: orange

🎯 Your Turn 2 — Report the F1 Score

On imbalanced problems (fraud, disease, spam) accuracy is misleading and the F1 score rules. Finish the recall and F1 formulas below, then check your answer against the expected output.

# 🎯 YOUR TURN 2 — report the F1 score
# Accuracy lies when classes are imbalanced. F1 balances precision
# and recall into one number. Finish the two blanks.

# Results from evaluating a model (positive class = fraud)
tp, fp, fn = 8, 2, 4

precision = tp / (tp + fp)             # of flagged items, how many were right
recall = tp / (tp + ___)              # 👉 of all real positives, how many we caught

# F1 is the harmonic mean of precision and recall
f1 = 2 * precision * recall / (precision + ___)   # 👉 add 'recall' here

print("Precision:", round(precision, 2))
print("Recall:   ", round(recall, 2))
print("F1 score: ", round(f1, 2))

# ✅ Expected output:
# Precision: 0.8
# Recall:    0.67
# F1 score:  0.73

🏁 Stretch Challenge — Package It Into a Class

Support has faded — only a comment outline remains. Combine Milestones 2, 3, and 5 into a single reusable FruitClassifier class with fit() and predict() methods. This mirrors exactly how scikit-learn estimators are structured.

# 🏁 STRETCH CHALLENGE — wrap the whole pipeline into one class
# Support has faded: only the outline is here. You write the logic.
#
# Goal: a FruitClassifier you can train once and reuse.
#
# class FruitClassifier:
#     def fit(self, X, y):
#         # 1. store min/max of each column for scaling (Milestone 2)
#         # 2. run the perceptron training loop (Milestone 3)
#         # 3. keep self.weights and self.bias
#         ...
#
#     def predict(self, sample):
#         # 1. scale the sample with the stored min/max
#         # 2. compute weights . features + bias
#         # 3. return "orange" if total >= 0 else "apple"
#         ...
#
# Then:
#   model = FruitClassifier()
#   model.fit(raw_X, raw_y)
#   print(model.predict([205, 8]))   # -> orange
#
# ✅ Expected (heavy, bumpy fruit): orange

# your code here

7 Common Pitfalls (And How to Fix Them)

These are the mistakes that quietly ruin ML projects. Spotting them is what separates a working model from a misleading one.

❌ Data leakage — testing on training data

Evaluating on rows the model already saw gives a fake high score:

model.fit(X, y)
score = evaluate(model, X, y)   # ❌ same data — score is meaningless

✅ Fix: always hold out a test set the model never trains on.

model.fit(X_train, y_train)
score = evaluate(model, X_test, y_test)   # ✅ honest score

❌ Forgetting to scale new data the same way

Re-fitting the scaler on new data shifts the numbers and breaks predictions:

new_min, new_max = min(new_X), max(new_X)   # ❌ different scale!

✅ Fix: reuse the training set's min/max for every prediction.

scaled = (value - train_min) / (train_max - train_min)   # ✅ same scale

❌ Trusting accuracy on imbalanced data

With 98% legitimate transactions, predicting "always legitimate" scores 98% and catches zero fraud.

✅ Fix: report precision, recall, and F1 — not accuracy alone.

❌ Expecting a perceptron to learn non-linear data

A single perceptron can only draw one straight line. If two classes can't be split by a line, it will never converge.

✅ Fix: cap the epochs, or switch to k-NN or a multi-layer network for tangled data.

📋 Quick Reference — The ML Workflow

StepWhat You Doscikit-learn equivalent
1. Load & inspectRead data, check size & class balancepd.read_csv(), df.head()
2. Preprocess & splitEncode labels, scale features, hold out test setMinMaxScaler, train_test_split
3. TrainFit the model's weights on training datamodel.fit(X_train, y_train)
4. EvaluateConfusion matrix, accuracy, precision, recall, F1accuracy_score, f1_score
5. PredictScale new data, output a labelmodel.predict(new_X)

Where This Takes You — AI/ML Career Paths

Career PathCore SkillsAverage Salary (USD)
ML EngineerPython, PyTorch, MLOps, Cloud$120K – $200K
Data ScientistStatistics, ML, SQL, Visualisation$100K – $170K
AI Research ScientistDeep Learning, Math, Publications$150K – $300K+
NLP EngineerTransformers, LLMs, RAG, Embeddings$130K – $220K
Computer Vision EngineerCNNs, Detection, Segmentation$120K – $190K
MLOps EngineerPipelines, Docker, Kubernetes, CI/CD$110K – $180K

🪙 Business Opportunities You Can Build

Turn This Into a Portfolio Project 🧠

📚 Structure Your ML Project

project/
 ┣ data/
 ┃ ┣ raw/              # Original datasets
 ┃ ┗ processed/         # Cleaned data
 ┣ notebooks/
 ┃ ┗ exploration.ipynb  # EDA and analysis
 ┣ src/
 ┃ ┣ data_pipeline.py   # Preprocessing
 ┃ ┣ features.py        # Feature engineering
 ┃ ┣ model.py           # Model architecture
 ┃ ┣ train.py           # Training loop
 ┃ ┗ evaluate.py        # Metrics + reporting
 ┣ api/
 ┃ ┗ main.py            # FastAPI inference server
 ┣ tests/               # Unit tests
 ┣ models/              # Saved model weights
 ┣ requirements.txt
 ┗ README.md

🧩 Start Small, Iterate

Begin with a baseline model (logistic regression or random forest), evaluate properly, then improve step by step. Track every experiment with MLflow.

🧪 Evaluate Thoroughly

Use appropriate metrics — F1 for imbalanced data, RMSE for regression, NDCG for rankings. Never rely on accuracy alone.

🌐 Deploy & Showcase

❓ Frequently Asked Questions

Course Complete — you built a full ML system end-to-end!

You loaded data, preprocessed and split it, trained a perceptron from scratch, evaluated it with a confusion matrix, predicted on unseen fruit, and connected all of it to scikit-learn. That five-step loop — load, preprocess, train, evaluate, predict — is the backbone of every machine learning project, from spam filters to self-driving cars.

Where to go next

🏆 Congratulations — you've completed the entire AI & Machine Learning course. From your first linear regression to LLMs, computer vision, reinforcement learning, and production MLOps, you've mastered the full AI engineering stack. Now go build something real.

Practice quiz

What are the five milestones of the ML workflow in this project?

  • Load, preprocess, train, evaluate, predict
  • Import, compile, run, debug, deploy
  • Collect, label, encrypt, store, delete
  • Plan, design, build, test, ship

Answer: Load, preprocess, train, evaluate, predict. The universal ML workflow is: load and inspect, preprocess and split, train, evaluate, then predict.

Why do you split data into train and test sets?

  • To make training faster
  • To evaluate the model on data it has never seen
  • To double the dataset size
  • To balance the classes

Answer: To evaluate the model on data it has never seen. You hold out a test set so you can measure performance on unseen data — never test on rows the model trained on.

Why scale features before training the perceptron?

  • So a large-valued feature doesn't drown out a small-valued one
  • To convert text into numbers
  • To remove the bias term
  • Scaling is never needed

Answer: So a large-valued feature doesn't drown out a small-valued one. Without scaling, weight (~200) would dwarf bumpiness (~10), so min-max normalisation puts them on equal footing.

When fitting a scaler, what is the correct practice?

  • Fit it on the whole dataset including test
  • Fit it on training data only, then reuse those min/max on test and new data
  • Re-fit a new scaler for every prediction
  • Never reuse the scaler

Answer: Fit it on training data only, then reuse those min/max on test and new data. Fit the scaler on training data only and reuse those same min/max values on test and new data to avoid leakage.

How does a perceptron make a prediction?

  • It stores all data and votes among nearest neighbours
  • It multiplies features by weights, adds a bias, and fires 1 if the total is positive
  • It averages all training labels
  • It picks a random class

Answer: It multiplies features by weights, adds a bias, and fires 1 if the total is positive. A perceptron computes a weighted sum plus bias and applies a step activation: 1 if positive, else 0.

What is the perceptron update rule doing when it makes a mistake?

  • Nudging the weights toward the correct answer
  • Resetting all weights to zero
  • Removing the misclassified sample
  • Increasing the learning rate

Answer: Nudging the weights toward the correct answer. On an error it adjusts each weight by learning_rate * error * feature, moving toward the right answer.

In a confusion matrix, what is a false negative (FN)?

  • Predicted positive, actually positive
  • Predicted positive, actually negative
  • Predicted negative, actually positive — a miss
  • Predicted negative, actually negative

Answer: Predicted negative, actually positive — a miss. A false negative is predicting negative when the truth is positive — for example missing an actual orange.

What does recall measure?

  • Of all flagged items, how many were truly positive
  • Of all real positives, how many the model caught
  • Overall correctness across both classes
  • The model's training time

Answer: Of all real positives, how many the model caught. Recall = TP / (TP + FN): of all real positives, the fraction the model correctly identified.

Why isn't accuracy enough on imbalanced data?

  • Accuracy is impossible to compute
  • A model can score high by always predicting the majority class while missing the rare class
  • Accuracy only works for regression
  • Accuracy ignores the training set

Answer: A model can score high by always predicting the majority class while missing the rare class. With 98% legitimate transactions, always predicting 'legitimate' scores 98% but catches zero fraud — F1 exposes this.

How does k-nearest neighbours (k-NN) classify a new point?

  • By learning a single straight decision boundary during training
  • By voting among the closest stored labelled points at prediction time
  • By averaging all feature values
  • By adding Gaussian noise

Answer: By voting among the closest stored labelled points at prediction time. k-NN learns nothing up front; at prediction time it finds the closest stored points and takes a majority vote.

Continue this course

Frequently asked questions

Why build a machine learning model from scratch instead of just using scikit-learn?

Writing a perceptron and k-NN in plain Python forces you to understand the maths — weighted sums, the update rule, distance, and the evaluation metrics. Once that clicks, scikit-learn stops being magic: you know exactly what fit() and predict() are doing under the hood, which makes you far better at debugging real models.

What is the difference between a perceptron and k-nearest neighbours?

A perceptron learns a single straight decision boundary by adjusting weights during training, so prediction is instant afterwards. k-NN learns nothing up front — it stores all the data and, at prediction time, votes among the closest stored points. Perceptron is fast but only handles linearly separable data; k-NN is flexible but slow on large datasets.

Why do I scale features before training?

Without scaling, a feature with large values (weight in grams, ~200) dwarfs a feature with small values (bumpiness, ~10), so the model effectively ignores the small one. Min-max normalisation puts every feature in the same 0-1 range so each contributes fairly. Crucially, you fit the scaler on training data only, then reuse those same min/max values on test and new data.

Why isn't accuracy enough to evaluate a classifier?

If 98% of transactions are legitimate, a model that always predicts 'legitimate' scores 98% accuracy while catching zero fraud. Precision (how many flagged items were truly positive) and recall (how many real positives you caught) expose that failure. F1 combines both into one number, which is why imbalanced problems are judged on F1, not accuracy.

What should I build next to turn this into a portfolio project?

Swap the toy fruit data for a real CSV (the Iris or Titanic datasets are classics), rewrite the model with scikit-learn, then wrap predict() in a small FastAPI endpoint and deploy it. Add a README with your metrics and a confusion matrix image. That end-to-end story — data, model, evaluation, deployment — is exactly what hiring managers look for.

Related lessons