Contents

Week 13: Machine Learning with scikit-learn

Month 4 | Main goal: The student understands what Machine Learning is, trains a price prediction model and a classification model, evaluates them, saves a model, and then serves it through FastAPI.


Teacher Preparation (read before the week starts)

  • Read:
  • Practise on your own: Run ALL the code from Sessions 1–3 on your first computer before class. This matters: the results (numbers) can differ slightly, and you need to know them.
  • Install (on the teaching computer, and make sure the student has it too):

    powershell
    mkdir wiki13_ml
    cd wiki13_ml
    python -m venv venv
    .\venv\Scripts\Activate.ps1
    pip install scikit-learn pandas numpy matplotlib joblib fastapi "uvicorn[standard]" pytest httpx
    

    httpx is needed by FastAPI's TestClient in pytest.

  • Remember: The student already knows NumPy, pandas, matplotlib and FastAPI. This week we bring all of that together. Don't be afraid of the maths: we don't go into the internal mathematics; we use ML as a "tool".

  • Prepare a real-life example: The price of plots of land (Dodoma, Kibaha, Bagamoyo...). The student will understand quickly when they see an example from home.

Week Goals (by the end of the week the student will be able to...)

  1. Explain in their own words what Machine Learning is, and the difference between supervised and unsupervised learning.
  2. Distinguish features (X: the information we know) from the label/target (y: what we want to predict).
  3. Split data with train_test_split and explain WHY we do it.
  4. Train a LinearRegression to predict the price of a plot and evaluate it with MAE and R².
  5. Train LogisticRegression and DecisionTreeClassifier on the iris flower data and evaluate them with accuracy.
  6. Explain overfitting in simple words and recognise it (very high train score, low test score).
  7. Save a model with joblib and load it again.
  8. Build a FastAPI endpoint POST /tabiri (predict) that returns a prediction, along with a pytest test.

Session 1 — Introduction to ML and LinearRegression

Session flow

  1. Review of the Week 12 homework (pandas/matplotlib) + greetings
  2. What is ML? Supervised vs Unsupervised, features vs labels (discussion + examples)
  3. Setting up the project: venv, pip install, creating the plot data
  4. Live coding: LinearRegression, train_test_split, predict
  5. Short break / questions
  6. In-class exercise
  7. Q&A + homework

Explanation for the teacher (read this first)

Ordinary programs vs Machine Learning. In ordinary programs (the ones we wrote in Weeks 1–12), we are the ones who write the rules: if ukubwa > 1000: bei = ... (ukubwa = size, bei = price). In Machine Learning, we give the computer many examples (data) together with the correct answers, and the computer learns the rules by itself. The result of that learning is called a model.

text
Programu ya kawaida:  Data + Sheria  ->  Majibu
Machine Learning:     Data + Majibu  ->  Sheria (model)

Key terms:

  • Features (X): The information we know about each example. For example: the size of the plot (m²), the distance from town (km). It is usually a DataFrame/table: each row is one example, each column is one feature. We write X in capitals because it is a table (2D).
  • Label / Target (y): What we want to predict. For example: the price of the plot. It is a single column (1D), so we write y in lower case.
  • Training: model.fit(X, y): the model looks at the examples and learns the relationship.
  • Prediction: model.predict(X_mpya) (X_mpya = new X): the model gives an answer for an example it has never seen.

Two main types:

  1. Supervised learning: The data has answers (labels). The teacher gives the student questions TOGETHER with answers. It has two types:
    • Regression: the answer is a number (price, temperature, salary).
    • Classification: the answer is a group/category (spam or not spam; type of flower; sick or healthy).
  2. Unsupervised learning: The data has NO answers. The model finds groups or patterns by itself. For example: dividing a shop's customers into groups based on their buying habits (clustering: KMeans).

Why train_test_split? If you teach a student with 100 questions and then examine them on the same 100 questions, you aren't testing understanding; you are testing memorisation. So we split the data: a large part (e.g. 80%) for training (train), and a small part (20%) for testing (test) that the model has never seen. random_state=42 makes the split the same every time we run it (so the results match).

LinearRegression in brief. It finds the line (or "plane" with many features) that best fits the data: bei = a*ukubwa + b*umbali + c (bei = price, ukubwa = size, umbali = distance). After fit, model.coef_ is [a, b] and model.intercept_ is c. That is why it is called "linear": the relationship is a straight line.

The scikit-learn structure (repeated for ALL models):

python
model = JinaLaModel()      # 1. tengeneza
model.fit(X_train, y_train)  # 2. fundisha
model.predict(X_test)        # 3. tabiri

This is good news: once you know one model, you know how to use them all.

Say this to the student

"Today we start something completely new and exciting: Machine Learning. Imagine a plot broker in Dodoma. He has been selling plots for ten years. If you show him a new plot, he tells you 'this one is about 45 million'. He hasn't used any formula; he has used his experience of the many plots he has seen. Machine Learning is giving the computer that experience through data."

"Features are the questions the broker asks: how big is the plot? how far is it from the main road? The label is the answer: what price did it sell for?"

"Supervised is like primary school: the teacher gives you the question and the answer, and you learn. Unsupervised is like being handed a bag of mixed beans with no instructions, and deciding for yourself to sort them by colour."

"Train/test split is like a teacher who hides the exam questions. If you only memorise the classroom questions, you will fail an exam with new questions. We want the model to understand, not memorise."

Type this code live (share screen)

Step 1: Create sample data — file tengeneza_data.py (create data)

Explain: We don't have real plot data, so we generate it with NumPy. We know the "truth" (0.05 million per m², minus 1.2 million per km), and then we add "noise" as in real life. Later we will see whether the model discovers that truth by itself!

python
# tengeneza_data.py
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 200

ukubwa_m2 = rng.integers(300, 2000, n)        # m² 300 hadi 2000
umbali_km = rng.uniform(1, 30, n).round(1)    # km 1 hadi 30 kutoka mjini
kelele = rng.normal(0, 5, n)                  # "noise" ya maisha halisi

# Bei kwa milioni za TSh
bei_milioni = 0.05 * ukubwa_m2 - 1.2 * umbali_km + 40 + kelele

df = pd.DataFrame({
    "ukubwa_m2": ukubwa_m2,
    "umbali_km": umbali_km,
    "bei_milioni": bei_milioni.round(1),
})
df.to_csv("viwanja.csv", index=False)
print(df.head())
print("Idadi ya safu:", len(df))

Run:

powershell
python tengeneza_data.py

The output will look like this (the exact numbers may differ slightly):

text
   ukubwa_m2  umbali_km  bei_milioni
0        451       27.3         28.3
1       1615       21.3         94.7
...
Idadi ya safu: 200

Step 2: Look at the data first — file kipindi1.py (session 1)

python
import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv("viwanja.csv")
print(df.describe())

plt.scatter(df["ukubwa_m2"], df["bei_milioni"])
plt.xlabel("Ukubwa (m²)")
plt.ylabel("Bei (milioni TSh)")
plt.title("Ukubwa vs Bei ya kiwanja")
plt.show()

Ask: "What do you see on the graph?" (Answer: as the size increases, the price increases: a rising line.)

Step 3: Separate X and y, split train/test

python
from sklearn.model_selection import train_test_split

X = df[["ukubwa_m2", "umbali_km"]]   # mabano MAWILI -> DataFrame (2D)
y = df["bei_milioni"]                 # mabano MOJA  -> Series (1D)

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)
print("Train:", X_train.shape, " Test:", X_test.shape)

Output:

text
Train: (160, 2)  Test: (40, 2)

Step 4: Train the model

python
from sklearn.linear_model import LinearRegression

model = LinearRegression()
model.fit(X_train, y_train)

print("Coefficients:", model.coef_)
print("Intercept:", model.intercept_)

Output (approximately):

text
Coefficients: [ 0.0501 -1.2522]
Intercept: 40.75

The "wow" moment! Say: "Look! We put 0.05 and -1.2 and 40 into the data. The model wasn't told; it discovered them by itself just from the examples. That is Machine Learning."

Step 5: Predict

python
# Tabiri kwa data ya test
utabiri = model.predict(X_test)
for halisi, tabiri in list(zip(y_test, utabiri))[:5]:
    print(f"Halisi: {halisi:7.1f}  |  Utabiri: {tabiri:7.1f}")

# Tabiri kiwanja kipya kabisa: m² 1000, km 10
kiwanja_kipya = pd.DataFrame({"ukubwa_m2": [1000], "umbali_km": [10]})
bei = model.predict(kiwanja_kipya)[0]
print(f"Kiwanja cha m² 1000, km 10: takriban milioni {bei:.1f}")

Output (approximately):

text
Halisi:    65.8  |  Utabiri:    72.6
Halisi:    89.2  |  Utabiri:    90.4
...
Kiwanja cha m² 1000, km 10: takriban milioni 78.3

Questions to ask the student

  1. Question: In a project to predict salaries, what could the features be and what is the label? Answer: Features: years of experience, education level, city, job. Label: salary.

  2. Question: Predicting whether an email is spam: is that regression or classification? Answer: Classification (the answer is a group: spam / not spam).

  3. Question: Why do we write df[["ukubwa_m2"]] (double brackets) for X? Answer: Because scikit-learn wants X to be 2D (a table). Double brackets return a DataFrame; single brackets return a Series (1D).

  4. Question: If we remove random_state=42, what changes? Answer: The train/test split will be different each time, so the result numbers will change slightly on every run.

  5. Question: Dividing customers into groups without knowing those groups in advance is what type of ML? Answer: Unsupervised learning (clustering).

Common mistakes

Mistake Error message Fix
Using single brackets for X ValueError: Expected 2D array, got 1D array instead Use df[["column"]] or .values.reshape(-1, 1)
Forgetting to install ModuleNotFoundError: No module named 'sklearn' pip install scikit-learn (the pip name is scikit-learn, the import name is sklearn)
pip install sklearn The 'sklearn' PyPI package is deprecated, use 'scikit-learn' Use pip install scikit-learn
Predicting before fit NotFittedError: This LinearRegression instance is not fitted yet Call model.fit(X_train, y_train) first
Predicting with a list without column names UserWarning: X does not have valid feature names... Use a DataFrame with the same column names as at fit time
venv not activated ModuleNotFoundError even though you installed it .\venv\Scripts\Activate.ps1, and in VS Code choose the venv interpreter (Ctrl+Shift+P → "Python: Select Interpreter")
PowerShell refuses the script running scripts is disabled on this system Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

In-class exercise

Exercise 1.1: Train the model using just ONE feature (ukubwa_m2). Print the coefficient and intercept. Predict the price of a 1500 m² plot.

Exercise 1.2: Predict the price of three plots at once: (500 m², 5 km), (1200 m², 20 km), (1800 m², 2 km). Print each one with neat formatting.

Exercise 1.3 (challenge): Draw a scatter plot of y_test (x-axis) against utabiri (prediction, y-axis). Add the "perfection" line y = x. If the points are close to the line, the model is good.

Answer
python
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression

df = pd.read_csv("viwanja.csv")

# Zoezi 1.1
X1 = df[["ukubwa_m2"]]
y = df["bei_milioni"]
X1_train, X1_test, y1_train, y1_test = train_test_split(X1, y, test_size=0.2, random_state=42)
m1 = LinearRegression().fit(X1_train, y1_train)
print("Coef:", m1.coef_, "Intercept:", m1.intercept_)
print("m² 1500:", m1.predict(pd.DataFrame({"ukubwa_m2": [1500]}))[0])

# Zoezi 1.2
X = df[["ukubwa_m2", "umbali_km"]]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = LinearRegression().fit(X_train, y_train)

viwanja = pd.DataFrame({
    "ukubwa_m2": [500, 1200, 1800],
    "umbali_km": [5, 20, 2],
})
for (_, safu), bei in zip(viwanja.iterrows(), model.predict(viwanja)):
    print(f"{safu.ukubwa_m2:.0f} m², {safu.umbali_km:.0f} km -> milioni {bei:.1f}")

# Zoezi 1.3
utabiri = model.predict(X_test)
plt.scatter(y_test, utabiri)
plt.plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], color="red")
plt.xlabel("Bei halisi")
plt.ylabel("Bei iliyotabiriwa")
plt.title("Halisi vs Utabiri")
plt.show()

Session 2 — Evaluating a model, Classification and Overfitting

Session flow

  1. Review of the homework (Session 1)
  2. Regression metrics: MAE and R² (live)
  3. Classification: iris data, LogisticRegression, accuracy, confusion matrix
  4. DecisionTree and Overfitting (train vs test score)
  5. Unsupervised, quickly: KMeans
  6. In-class exercise
  7. Q&A + homework

Explanation for the teacher (read this first)

Evaluating regression:

  • MAE (Mean Absolute Error): The average of "how far off were you", regardless of whether it is above or below. MAE = 4.0 means "on average the prediction is off by 4 million". It is easy to explain because it is in the same unit as y (millions). Smaller is better.
  • R² (R-squared, "coefficient of determination"): Between 0 and 1 (it can be negative for a very bad model). It tells you what percentage of the variation in price the model explains. R² = 0.95 ≈ "the model explains 95% of the differences in price". Bigger is better. For regression, model.score(X_test, y_test) returns R².

Classification:

  • The iris data is built into scikit-learn: 150 flowers of 3 types (setosa, versicolor, virginica). 4 features: the length and width of the sepal (the flower's outer leaves) and the petal. We don't need to download anything.
  • LogisticRegression: Despite the name "regression", it is a classification model. It estimates the probability of each group and picks the largest. predict_proba shows those probabilities.
  • Accuracy: the percentage of correct answers. 0.97 = 97% correct. For a classifier, model.score(X_test, y_test) returns accuracy.
  • Confusion matrix: A table showing what the model confused with what. The diagonal = correct.
  • stratify=y in train_test_split ensures each group has the same proportion in train and test.

DecisionTree: It's like the game of "20 questions": "Is petal length < 2.45? Yes → setosa. No → ask another question..." It is easy to explain and to visualise.

Overfitting (memorising instead of understanding): When a model is "too clever for its own good" (e.g. a very deep tree), it memorises every bit of noise in the train data. The result: train score ≈ 100%, test score much lower. The main symptom: a big gap between train and test. Simple remedies: reduce complexity (max_depth=3), more data, or choose a simpler model. The opposite is underfitting: the model is too simple and does badly even on train.

On iris, the data is easy so the gap may be small. That's why we will also use the noisy plot data with an unlimited DecisionTreeRegressor; there, overfitting is clearly visible.

KMeans (unsupervised): You tell it "I want 3 groups", and it finds 3 centres and assigns each point to the nearest centre. We don't give it y.

Say this to the student

"Last session we built a model. Today the question is: how good is it? MAE is like asking the broker 'how far off are you usually?' If he says '4 million', you know how much you can trust him."

"Overfitting is like a student who has memorised all the past papers word for word. Give them a past paper and they get 100. Give them a new question and they fail. A good model is like the student who understood the concepts."

"A decision tree is like a clinic doctor: 'Do you have a fever? Yes. Are you shivering? Yes. Malaria test?' Yes/no questions until reaching an answer."

Type this code live (share screen)

Part A: Regression metrics — kipindi2_metrics.py

python
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, r2_score

df = pd.read_csv("viwanja.csv")
X = df[["ukubwa_m2", "umbali_km"]]
y = df["bei_milioni"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

model = LinearRegression().fit(X_train, y_train)
utabiri = model.predict(X_test)

print(f"MAE: {mean_absolute_error(y_test, utabiri):.2f} milioni")
print(f"R² : {r2_score(y_test, utabiri):.3f}")
print(f"model.score (R²): {model.score(X_test, y_test):.3f}")

Output (approximately):

text
MAE: 3.76 milioni
R² : 0.971
model.score (R²): 0.971

Explain: "MAE ≈ 3.8 matches the noise we added (normal with 5). The model can't predict noise; that's the randomness of life."

Part B: Classification on iris — kipindi2_iris.py

python
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, confusion_matrix, classification_report

iris = load_iris(as_frame=True)
X = iris.data          # DataFrame yenye columns 4
y = iris.target        # 0, 1, 2
print(X.head())
print("Majina ya aina:", list(iris.target_names))

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

clf = LogisticRegression(max_iter=200)
clf.fit(X_train, y_train)

utabiri = clf.predict(X_test)
print("Accuracy:", accuracy_score(y_test, utabiri))
print(confusion_matrix(y_test, utabiri))
print(classification_report(y_test, utabiri, target_names=iris.target_names))

Output (approximately):

text
   sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)
0                5.1               3.5                1.4               0.2
...
Majina ya aina: ['setosa', 'versicolor', 'virginica']
Accuracy: 0.9666666666666667
[[10  0  0]
 [ 0  9  1]
 [ 0  0 10]]

Explain the confusion matrix: "Row = the true answer, column = the prediction. The numbers on the diagonal are correct. Here 1 versicolor flower was taken to be virginica."

Predict a new flower with probabilities:

python
import pandas as pd

ua_jipya = pd.DataFrame([[5.0, 3.4, 1.5, 0.2]], columns=X.columns)
namba = clf.predict(ua_jipya)[0]
print("Aina:", iris.target_names[namba])
print("Uwezekano:", clf.predict_proba(ua_jipya).round(3))

Output:

text
Aina: setosa
Uwezekano: [[0.97 0.03 0.  ]]

Part C: DecisionTree and overfitting — kipindi2_overfit.py

python
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeRegressor

df = pd.read_csv("viwanja.csv")
X = df[["ukubwa_m2", "umbali_km"]]
y = df["bei_milioni"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

for kina in [1, 2, 3, 5, 10, None]:
    tree = DecisionTreeRegressor(max_depth=kina, random_state=42)
    tree.fit(X_train, y_train)
    print(f"max_depth={str(kina):>4}  train R²={tree.score(X_train, y_train):.3f}  "
          f"test R²={tree.score(X_test, y_test):.3f}")

Output (approximately; look at the trend, not the exact numbers):

text
max_depth=   1  train R²=0.614  test R²=0.578
max_depth=   2  train R²=0.798  test R²=0.711
max_depth=   3  train R²=0.906  test R²=0.880
max_depth=   5  train R²=0.974  test R²=0.931
max_depth=  10  train R²=1.000  test R²=0.933
max_depth=None  train R²=1.000  test R²=0.931

Say: "Look at None: train is 1.000, meaning it has memorised every plot. But test is stuck at around 0.93. The gap between train and test (1.00 vs 0.93) is a sign of memorising. That is overfitting. And max_depth=1 is underfitting: too simple."
Also compare: LinearRegression got a test R² ≈ 0.97. A simple model that is right for this problem beats a complex model.

DecisionTreeClassifier on iris + viewing the tree:

python
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier, plot_tree
import matplotlib.pyplot as plt

iris = load_iris(as_frame=True)
X_train, X_test, y_train, y_test = train_test_split(
    iris.data, iris.target, test_size=0.2, random_state=42, stratify=iris.target
)
mti = DecisionTreeClassifier(max_depth=3, random_state=42).fit(X_train, y_train)
print("Train acc:", mti.score(X_train, y_train))
print("Test acc :", mti.score(X_test, y_test))

plt.figure(figsize=(12, 6))
plot_tree(mti, feature_names=iris.feature_names, class_names=iris.target_names, filled=True)
plt.show()

Part D: KMeans, quickly (unsupervised)

python
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.datasets import load_iris

iris = load_iris(as_frame=True)
km = KMeans(n_clusters=3, n_init=10, random_state=42)
makundi = km.fit_predict(iris.data)   # Hatuipi y!
print(makundi[:10])
print(pd.crosstab(makundi, iris.target))

Output (the group numbers 0/1/2 are only names; they don't necessarily match the target):

text
target   0   1   2
row_0
0        0  48  14
1       50   0   0
2        0   2  36

Say: "We didn't give it the answers, but it discovered groups very similar to the real flower types." Group 1 = all 50 setosa. Versicolor and virginica mix a little because they are similar.

Questions to ask the student

  1. Question: MAE = 10 and MAE = 3: which is better? Answer: 3 (smaller errors).
  2. Question: A model has train accuracy 100% and test accuracy 60%. What is the problem? Answer: Overfitting: it has memorised the train data.
  3. Question: Why is LogisticRegression classification even though its name has "regression"? Answer: It estimates the probabilities of groups and chooses a group; the name is historical.
  4. Question: What does stratify=y do? Answer: It ensures the proportion of groups in test matches that of the whole data.
  5. Question: Why doesn't KMeans need y? Answer: It is unsupervised: it finds groups by itself without answers.

Common mistakes

Mistake Message Fix
LogisticRegression didn't finish ConvergenceWarning: lbfgs failed to converge Increase to max_iter=200 or more
Mixing up the order of the variables Strange accuracy or an error train_test_split returns X_train, X_test, y_train, y_test, in that order
Using accuracy for regression ValueError: continuous is not supported For regression use MAE/R²; for classification use accuracy
Evaluating on train data Numbers that are "too good" Evaluate on X_test, y_test
Forgetting n_init in KMeans (older versions) FutureWarning about n_init Set n_init=10 explicitly

In-class exercise

Exercise 2.1: Train a DecisionTreeClassifier on iris with max_depth 1, 2, 3, 5, None. Print the train and test accuracy for each. Which depth is best?

Exercise 2.2: Compare LogisticRegression and DecisionTreeClassifier(max_depth=3) on iris: which has the higher test accuracy?

Exercise 2.3: On the plot data, calculate the MAE of a "dumb" model that always predicts the mean of y_train. Compare with the MAE of LinearRegression. (This is called a baseline.)

Answer
python
import numpy as np
import pandas as pd
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.linear_model import LogisticRegression, LinearRegression
from sklearn.metrics import mean_absolute_error

# 2.1
iris = load_iris(as_frame=True)
X_train, X_test, y_train, y_test = train_test_split(
    iris.data, iris.target, test_size=0.2, random_state=42, stratify=iris.target
)
for kina in [1, 2, 3, 5, None]:
    m = DecisionTreeClassifier(max_depth=kina, random_state=42).fit(X_train, y_train)
    print(f"kina={kina}: train={m.score(X_train, y_train):.2f} test={m.score(X_test, y_test):.2f}")
# Matokeo: kina 3 ni bora (train 0.98, test 0.97). Kina 5/None: train 1.00 lakini test 0.93 -> overfitting.

# 2.2
lr = LogisticRegression(max_iter=200).fit(X_train, y_train)
dt = DecisionTreeClassifier(max_depth=3, random_state=42).fit(X_train, y_train)
print("Logistic:", lr.score(X_test, y_test))
print("Tree    :", dt.score(X_test, y_test))

# 2.3
df = pd.read_csv("viwanja.csv")
X = df[["ukubwa_m2", "umbali_km"]]
y = df["bei_milioni"]
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)
baseline = np.full(len(yte), ytr.mean())
print("Baseline MAE:", mean_absolute_error(yte, baseline))
model = LinearRegression().fit(Xtr, ytr)
print("LinearRegression MAE:", mean_absolute_error(yte, model.predict(Xte)))
# Baseline ≈ 23.1, LinearRegression ≈ 3.8 -> model ina thamani.

Session 3 — Saving a model and serving it through FastAPI

Session flow

  1. Review of the homework (Session 2)
  2. Why save a model? joblib dump/load (live)
  3. Pipeline (optional): joining steps together
  4. FastAPI: endpoints POST /tabiri (predict) and POST /tabiri-ua (predict flower)
  5. pytest for the endpoint
  6. In-class exercise
  7. Q&A + the week's homework

Explanation for the teacher (read this first)

The problem: Training a model can take a while (seconds to hours with large data). We don't want to retrain every time a user requests a prediction. So:

  1. The training script (fundisha.py, "train") is run once: it reads the data, trains, evaluates and saves the model to a file (model_viwanja.joblib, the plots model).
  2. The API (main.py) loads that file once when it starts, and then every request just calls model.predict(...) (very fast).

joblib: It saves a Python object (the model) to a file and brings it back. joblib.dump(model, "faili.joblib") and model = joblib.load("faili.joblib").

Security warning: Don't load a .joblib/.pkl file from a source you don't trust: it can run dangerous code when loaded. Only use a file you created yourself.
Versions: Load a model with the same scikit-learn version that created it, otherwise you may get InconsistentVersionWarning. That's why we write requirements.txt.

Pipeline (optional but good): make_pipeline(StandardScaler(), LogisticRegression()) joins the preparation steps (e.g. putting features on the same scale) and the model into one object. When you save the pipeline, you save all the steps, so there's no forgetting to do the scaling in the API.

FastAPI + model: We use Pydantic (they already know it) to validate input. We convert the input to a DataFrame with the correct column names, call predict, and convert the answer (numpy) into a Python float/int before returning JSON.

Say this to the student

"Think of a chapati cook. She doesn't knead the dough afresh every time a customer walks in. She kneads once in the morning, and then cooks quickly when a customer comes. fundisha.py is kneading the dough. The API is the quick cooking."

"joblib is like putting food in the fridge. You cook today and eat tomorrow without cooking again."

"Now your model can be used by a phone app, a website, or anyone with internet. That's how companies really use AI."

Type this code live (share screen)

Folder structure:

text
wiki13_ml/
├── viwanja.csv
├── fundisha.py
├── main.py
├── test_main.py
└── requirements.txt

Step 1: fundisha.py

python
# fundisha.py
import joblib
import pandas as pd
from sklearn.datasets import load_iris
from sklearn.linear_model import LinearRegression, LogisticRegression
from sklearn.metrics import mean_absolute_error, accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

# ---- Model 1: bei za viwanja ----
df = pd.read_csv("viwanja.csv")
X = df[["ukubwa_m2", "umbali_km"]]
y = df["bei_milioni"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

model_viwanja = LinearRegression().fit(X_train, y_train)
mae = mean_absolute_error(y_test, model_viwanja.predict(X_test))
print(f"Viwanja MAE: {mae:.2f} milioni")
joblib.dump(model_viwanja, "model_viwanja.joblib")

# ---- Model 2: aina ya ua (pipeline) ----
iris = load_iris(as_frame=True)
Xi_train, Xi_test, yi_train, yi_test = train_test_split(
    iris.data, iris.target, test_size=0.2, random_state=42, stratify=iris.target
)
model_iris = make_pipeline(StandardScaler(), LogisticRegression(max_iter=200))
model_iris.fit(Xi_train, yi_train)
print(f"Iris accuracy: {accuracy_score(yi_test, model_iris.predict(Xi_test)):.2f}")
joblib.dump(model_iris, "model_iris.joblib")

print("Models zimehifadhiwa!")
powershell
python fundisha.py
dir *.joblib

Output:

text
Viwanja MAE: 3.76 milioni
Iris accuracy: 0.93
Models zimehifadhiwa!

Step 2: Try loading it in the python shell

powershell
python
python
>>> import joblib, pandas as pd
>>> m = joblib.load("model_viwanja.joblib")
>>> m.predict(pd.DataFrame({"ukubwa_m2": [1000], "umbali_km": [10]}))
array([78.3496...])
>>> exit()

Step 3: main.py

python
# main.py
import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI(title="API ya Utabiri (Wiki 13)")

# Pakia models MARA MOJA programu inapoanza
model_viwanja = joblib.load("model_viwanja.joblib")
model_iris = joblib.load("model_iris.joblib")
AINA_ZA_MAUA = ["setosa", "versicolor", "virginica"]


class Kiwanja(BaseModel):
    ukubwa_m2: float = Field(gt=0, le=100_000, description="Ukubwa kwa mita za mraba")
    umbali_km: float = Field(ge=0, le=500, description="Umbali kutoka mjini kwa km")


class Ua(BaseModel):
    sepal_length: float = Field(gt=0)
    sepal_width: float = Field(gt=0)
    petal_length: float = Field(gt=0)
    petal_width: float = Field(gt=0)


@app.get("/")
def mwanzo():
    return {"ujumbe": "Karibu kwenye API ya ML. Nenda /docs"}


@app.post("/tabiri")
def tabiri_bei(kiwanja: Kiwanja):
    X = pd.DataFrame([kiwanja.model_dump()])
    bei = float(model_viwanja.predict(X)[0])
    return {"bei_milioni": round(bei, 2), "ingizo": kiwanja}


@app.post("/tabiri-ua")
def tabiri_ua(ua: Ua):
    # Majina ya columns lazima yalingane na yale ya iris.data
    X = pd.DataFrame([[ua.sepal_length, ua.sepal_width, ua.petal_length, ua.petal_width]],
                     columns=["sepal length (cm)", "sepal width (cm)",
                              "petal length (cm)", "petal width (cm)"])
    namba = int(model_iris.predict(X)[0])
    uwezekano = float(model_iris.predict_proba(X)[0][namba])
    return {"aina": AINA_ZA_MAUA[namba], "uhakika": round(uwezekano, 3)}
powershell
uvicorn main:app --reload

Open http://127.0.0.1:8000/docs → POST /tabiri → "Try it out" → enter:

json
{"ukubwa_m2": 1000, "umbali_km": 10}

Response:

json
{"bei_milioni": 78.35, "ingizo": {"ukubwa_m2": 1000, "umbali_km": 10}}

Also try bad input {"ukubwa_m2": -5, "umbali_km": 10} → 422 Unprocessable Entity (Pydantic has rejected it). Say: "This is Pydantic's job: it protects the model from being fed rubbish."

Step 4: test_main.py

python
# test_main.py
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)


def test_tabiri_bei_inafanya_kazi():
    r = client.post("/tabiri", json={"ukubwa_m2": 1000, "umbali_km": 10})
    assert r.status_code == 200
    bei = r.json()["bei_milioni"]
    assert 60 < bei < 95          # takriban milioni 78.35


def test_kiwanja_kikubwa_ni_ghali_zaidi():
    ndogo = client.post("/tabiri", json={"ukubwa_m2": 500, "umbali_km": 10}).json()
    kubwa = client.post("/tabiri", json={"ukubwa_m2": 1500, "umbali_km": 10}).json()
    assert kubwa["bei_milioni"] > ndogo["bei_milioni"]


def test_input_mbaya_inakataliwa():
    r = client.post("/tabiri", json={"ukubwa_m2": -5, "umbali_km": 10})
    assert r.status_code == 422


def test_tabiri_ua_setosa():
    r = client.post("/tabiri-ua", json={
        "sepal_length": 5.1, "sepal_width": 3.5, "petal_length": 1.4, "petal_width": 0.2
    })
    assert r.status_code == 200
    assert r.json()["aina"] == "setosa"
powershell
pytest -v

Output:

text
test_main.py::test_tabiri_bei_inafanya_kazi PASSED
test_main.py::test_kiwanja_kikubwa_ni_ghali_zaidi PASSED
test_main.py::test_input_mbaya_inakataliwa PASSED
test_main.py::test_tabiri_ua_setosa PASSED

Explain: "We don't test the exact number (78.35) because it can change slightly if we retrain. We test behaviour: a bigger plot is more expensive."

Step 5: requirements and Git

powershell
pip freeze > requirements.txt
git init
git add .
git commit -m "Wiki 13: ML API ya viwanja na iris"

Discussion question: Should we commit .joblib files? For small models like these it's fine. Large models (hundreds of MB) don't belong in Git; they are stored elsewhere. Don't commit venv/ (put it in .gitignore).

Questions to ask the student

  1. Question: Why don't we train the model inside the /tabiri endpoint? Answer: It would take time on every request and waste resources; we train once and load.
  2. Question: Why do we use float(...) in the response? Answer: predict returns NumPy types; converting them to Python types ensures the JSON is produced without problems.
  3. Question: What is the benefit of a Pipeline? Answer: All the steps (scaling + model) are saved together; the API doesn't forget to do the same preparation.
  4. Question: If someone emails you a model.joblib file, should you load it? Answer: No, unless you trust them completely: loading can run dangerous code.

Common mistakes

Mistake Message Fix
The model file is missing / you're in a different folder FileNotFoundError: [Errno 2] No such file or directory: 'model_viwanja.joblib' Run python fundisha.py first; run uvicorn from the same folder
Column names differ from those at training ValueError: The feature names should match those that were passed during fit Use exactly the same names (including spaces and "(cm)")
Returning numpy directly Serialization TypeError / ValueError (especially for numpy.int64) Convert with int()/float()
TestClient without httpx RuntimeError: The starlette.testclient module requires the httpx package pip install httpx
Different sklearn version InconsistentVersionWarning: Trying to unpickle estimator ... from version X when using version Y Retrain or use the same version (requirements.txt)
Port in use [Errno 10048] ... only one usage of each socket address Close the other uvicorn, or uvicorn main:app --reload --port 8001

In-class exercise

Exercise 3.1: Add an endpoint POST /tabiri-viwanja-vingi (predict many plots) that receives a list of plots and returns a list of prices. Example input: [{"ukubwa_m2": 500, "umbali_km": 5}, {"ukubwa_m2": 1500, "umbali_km": 20}]

Exercise 3.2: Add an endpoint GET /taarifa-za-model (model information) that returns the coefficients and intercept of the plots model.

Exercise 3.3: Write a pytest test for Exercise 3.1.

Answer
python
# ongeza kwenye main.py

@app.post("/tabiri-viwanja-vingi")
def tabiri_vingi(viwanja: list[Kiwanja]):
    if not viwanja:
        return {"bei_milioni": []}
    X = pd.DataFrame([k.model_dump() for k in viwanja])
    bei = model_viwanja.predict(X)
    return {"bei_milioni": [round(float(b), 2) for b in bei]}


@app.get("/taarifa-za-model")
def taarifa():
    return {
        "aina": type(model_viwanja).__name__,
        "features": list(model_viwanja.feature_names_in_),
        "coefficients": [float(c) for c in model_viwanja.coef_],
        "intercept": float(model_viwanja.intercept_),
    }
python
# ongeza kwenye test_main.py
def test_tabiri_vingi():
    r = client.post("/tabiri-viwanja-vingi", json=[
        {"ukubwa_m2": 500, "umbali_km": 5},
        {"ukubwa_m2": 1500, "umbali_km": 20},
    ])
    assert r.status_code == 200
    bei = r.json()["bei_milioni"]
    assert len(bei) == 2
    assert all(isinstance(b, float) for b in bei)

Homework

(Due: next session)

Mini project: "Predict exam scores"

  1. Create alama.csv (scores) with NumPy (n=300, random_state/seed 7) with the columns:
    • masaa_kusoma (study hours, 0–10), mahudhurio_asilimia (attendance percentage, 40–100), masaa_usingizi (sleep hours, 4–9)
    • alama (score) = 5*masaa_kusoma + 0.4*mahudhurio_asilimia + 2*masaa_usingizi + kelele(0, 5) (kelele = noise), clipped between 0 and 100 (np.clip).
  2. Train a LinearRegression, print the MAE and R² on test.
  3. Compare with DecisionTreeRegressor at max_depth 2, 4, None. Write (as a comment) which one is overfitting.
  4. Add a column amefaulu = alama >= 50 (amefaulu = passed), train a LogisticRegression to predict whether the student passed, print the accuracy.
  5. Save the regression model with joblib, and create an endpoint POST /tabiri-alama (predict score) in FastAPI.
Answer
python
# kazi_nyumbani_13.py
import numpy as np
import pandas as pd
import joblib
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression, LogisticRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.metrics import mean_absolute_error, r2_score, accuracy_score

rng = np.random.default_rng(7)
n = 300
df = pd.DataFrame({
    "masaa_kusoma": rng.uniform(0, 10, n).round(1),
    "mahudhurio_asilimia": rng.uniform(40, 100, n).round(0),
    "masaa_usingizi": rng.uniform(4, 9, n).round(1),
})
alama = (5 * df.masaa_kusoma + 0.4 * df.mahudhurio_asilimia
         + 2 * df.masaa_usingizi + rng.normal(0, 5, n))
df["alama"] = np.clip(alama, 0, 100).round(0)
df.to_csv("alama.csv", index=False)

FEATURES = ["masaa_kusoma", "mahudhurio_asilimia", "masaa_usingizi"]
X, y = df[FEATURES], df["alama"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

lr = LinearRegression().fit(X_train, y_train)
p = lr.predict(X_test)
print(f"Linear MAE={mean_absolute_error(y_test, p):.2f} R²={r2_score(y_test, p):.3f}")

for kina in [2, 4, None]:
    t = DecisionTreeRegressor(max_depth=kina, random_state=42).fit(X_train, y_train)
    print(f"Tree {kina}: train={t.score(X_train, y_train):.3f} test={t.score(X_test, y_test):.3f}")
# max_depth=None: train=1.000 lakini test chini -> overfitting

df["amefaulu"] = (df["alama"] >= 50).astype(int)
Xc_train, Xc_test, yc_train, yc_test = train_test_split(
    X, df["amefaulu"], test_size=0.2, random_state=42, stratify=df["amefaulu"]
)
clf = LogisticRegression(max_iter=500).fit(Xc_train, yc_train)
print("Accuracy:", accuracy_score(yc_test, clf.predict(Xc_test)))

joblib.dump(lr, "model_alama.joblib")
python
# main_alama.py   ->  uvicorn main_alama:app --reload
import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()
model = joblib.load("model_alama.joblib")

class Mwanafunzi(BaseModel):
    masaa_kusoma: float = Field(ge=0, le=24)
    mahudhurio_asilimia: float = Field(ge=0, le=100)
    masaa_usingizi: float = Field(ge=0, le=24)

@app.post("/tabiri-alama")
def tabiri(m: Mwanafunzi):
    X = pd.DataFrame([m.model_dump()])
    alama = float(model.predict(X)[0])
    alama = max(0.0, min(100.0, alama))
    return {"alama_inayotarajiwa": round(alama, 1), "atafaulu": alama >= 50}

If you are asked a question you don't know

Say honestly: "Good question. I'm not sure, let's look it up together in the documentation." Then search together. Good search terms: "scikit-learn example", "sklearn ".


End-of-week checklist

  • Explains the difference between an ordinary program and ML in their own words
  • Distinguishes supervised (regression/classification) from unsupervised (clustering), and gives an example of each
  • Separates X (2D) and y (1D) without error
  • Uses train_test_split and explains why
  • Trains and predicts with LinearRegression, reads coef_ and intercept_
  • Calculates and explains MAE and R²
  • Trains LogisticRegression/DecisionTree and measures accuracy
  • Recognises overfitting from train vs test score
  • Saves and loads a model with joblib
  • Has a working FastAPI endpoint and the pytest tests pass
  • Code is committed to Git