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:
- scikit-learn "Getting Started": https://scikit-learn.org/stable/getting_started.html
- The
train_test_splitpage: https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html - The "Model persistence" page (joblib): https://scikit-learn.org/stable/model_persistence.html
- 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):
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
httpxis needed by FastAPI'sTestClientin 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...)
- Explain in their own words what Machine Learning is, and the difference between supervised and unsupervised learning.
- Distinguish features (X: the information we know) from the label/target (y: what we want to predict).
- Split data with
train_test_splitand explain WHY we do it. - Train a
LinearRegressionto predict the price of a plot and evaluate it with MAE and R². - Train
LogisticRegressionandDecisionTreeClassifieron the iris flower data and evaluate them with accuracy. - Explain overfitting in simple words and recognise it (very high train score, low test score).
- Save a model with
jobliband load it again. - 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
- Review of the Week 12 homework (pandas/matplotlib) + greetings
- What is ML? Supervised vs Unsupervised, features vs labels (discussion + examples)
- Setting up the project: venv, pip install, creating the plot data
- Live coding: LinearRegression, train_test_split, predict
- Short break / questions
- In-class exercise
- 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.
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
Xin 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
yin 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:
- 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).
- 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):
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!
# 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:
python tengeneza_data.py
The output will look like this (the exact numbers may differ slightly):
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)
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
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:
Train: (160, 2) Test: (40, 2)
Step 4: Train the model
from sklearn.linear_model import LinearRegression model = LinearRegression() model.fit(X_train, y_train) print("Coefficients:", model.coef_) print("Intercept:", model.intercept_)
Output (approximately):
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
# 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):
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
-
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.
-
Question: Predicting whether an email is spam: is that regression or classification? Answer: Classification (the answer is a group: spam / not spam).
-
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). -
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. -
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
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
- Review of the homework (Session 1)
- Regression metrics: MAE and R² (live)
- Classification: iris data, LogisticRegression, accuracy, confusion matrix
- DecisionTree and Overfitting (train vs test score)
- Unsupervised, quickly: KMeans
- In-class exercise
- 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_probashows 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=yintrain_test_splitensures 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
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):
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
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):
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:
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:
Aina: setosa Uwezekano: [[0.97 0.03 0. ]]
Part C: DecisionTree and overfitting — kipindi2_overfit.py
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):
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:
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)
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):
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
- Question: MAE = 10 and MAE = 3: which is better? Answer: 3 (smaller errors).
- Question: A model has train accuracy 100% and test accuracy 60%. What is the problem? Answer: Overfitting: it has memorised the train data.
- 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.
- Question: What does
stratify=ydo? Answer: It ensures the proportion of groups in test matches that of the whole data. - 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
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
- Review of the homework (Session 2)
- Why save a model? joblib dump/load (live)
- Pipeline (optional): joining steps together
- FastAPI: endpoints
POST /tabiri(predict) andPOST /tabiri-ua(predict flower) - pytest for the endpoint
- In-class exercise
- 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:
- 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). - The API (
main.py) loads that file once when it starts, and then every request just callsmodel.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/.pklfile 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 getInconsistentVersionWarning. That's why we writerequirements.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.pyis 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:
wiki13_ml/ ├── viwanja.csv ├── fundisha.py ├── main.py ├── test_main.py └── requirements.txt
Step 1: fundisha.py
# 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!")
python fundisha.py dir *.joblib
Output:
Viwanja MAE: 3.76 milioni Iris accuracy: 0.93 Models zimehifadhiwa!
Step 2: Try loading it in the python shell
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
# 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)}
uvicorn main:app --reload
Open http://127.0.0.1:8000/docs → POST /tabiri → "Try it out" → enter:
{"ukubwa_m2": 1000, "umbali_km": 10}
Response:
{"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
# 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"
pytest -v
Output:
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
pip freeze > requirements.txt git init git add . git commit -m "Wiki 13: ML API ya viwanja na iris"
Discussion question: Should we commit
.joblibfiles? For small models like these it's fine. Large models (hundreds of MB) don't belong in Git; they are stored elsewhere. Don't commitvenv/(put it in.gitignore).
Questions to ask the student
- Question: Why don't we train the model inside the
/tabiriendpoint? Answer: It would take time on every request and waste resources; we train once and load. - Question: Why do we use
float(...)in the response? Answer:predictreturns NumPy types; converting them to Python types ensures the JSON is produced without problems. - 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.
- Question: If someone emails you a
model.joblibfile, 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
# 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_), }
# 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"
- 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).
- Train a
LinearRegression, print the MAE and R² on test. - Compare with
DecisionTreeRegressoratmax_depth2, 4, None. Write (as a comment) which one is overfitting. - Add a column
amefaulu = alama >= 50(amefaulu= passed), train aLogisticRegressionto predict whether the student passed, print the accuracy. - Save the regression model with joblib, and create an endpoint
POST /tabiri-alama(predict score) in FastAPI.
Answer
# 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")
# 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
- scikit-learn guide: https://scikit-learn.org/stable/user_guide.html
- Map for choosing a model: https://scikit-learn.org/stable/machine_learning_map.html
- FastAPI docs: https://fastapi.tiangolo.com/
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) andy(1D) without error - Uses
train_test_splitand explains why - Trains and predicts with
LinearRegression, readscoef_andintercept_ - 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