Building ML models in Python using OOP — regression and neural networks
Two tasks from Lesson 18. Think through your answers first, then reveal.
Good examples you might have spotted:
Key pattern: it adapts to data rather than following rules a programmer wrote.
Strong answers for each option:
These are real product decisions. The reasoning is what the HSC marks — not the answer itself.
The NESA syllabus says to build ML models "using an OOP." Before you write any model code, notice that you've already been doing OOP every time you use scikit-learn — you just hadn't thought about it that way.
Every scikit-learn model follows the same OOP pattern. You instantiate an object, call its methods, and read its attributes:
from sklearn.linear_model import LinearRegression
# Instantiate — creates a LinearRegression object with chosen hyperparameters
model = LinearRegression()
# fit() — trains the model (modifies the object's internal state)
model.fit(X_train, y_train)
# Attributes set by fit() — the model's learned knowledge
print(model.coef_) # slope(s)
print(model.intercept_) # y-intercept
# predict() — uses learned knowledge to answer new questions
predictions = model.predict(X_test)
# score() — evaluates accuracy on unseen data
r2 = model.score(X_test, y_test)
Set hyperparameters. No learning yet.
Train on data. Sets learned attributes.
Apply learned model to new data.
Evaluate on unseen data.
This pattern is identical for every model in this lesson — LinearRegression, PolynomialFeatures, LogisticRegression, MLPClassifier. Learn the four methods once; apply them everywhere.
Everything scikit-learn does is OOP by design. Spot the principles:
| OOP Concept | In scikit-learn ML |
|---|---|
| Encapsulation | The model stores its own learned state (coef_, intercept_). You don't touch it directly — the object manages it through fit(). |
| Abstraction | fit() hides all the linear algebra and gradient descent. You call one method; the maths happens inside. |
| Generalisation | LinearRegression, LogisticRegression, and MLPClassifier all share the same interface. One function can train any of them. |
| Message passing | model.fit(X, y) sends a message to the model object. The object acts on its own internal state. |
| Facade | You'll build your own MLPipeline class later in this lesson — it wraps the scaler + model complexity behind a clean interface. |
Testing a model on the same data it trained on is like marking your own exam paper. You'd give yourself 100% — but that tells you nothing about whether you actually learned. The test set is data the model has never seen during training. Its score is the only honest measure of performance.
from sklearn.model_selection import train_test_split
# 80% for training, 20% for testing
# random_state=42 makes the split the same every run (reproducible)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
You'll use this before training every model today.
NESA Linear regression predicts a continuous numeric value by fitting a straight line through the data.
The line is: y = mx + b — where m (slope) and b (intercept) are what the model learns from your training data.
Classic question: "If I study for X hours, what exam score should I expect?"
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
# ── Data ──────────────────────────────────────────────────────────────
study_hours = np.array([1, 2, 2.5, 3, 4, 4.5, 5, 6, 6.5, 7, 8, 8.5])
exam_scores = np.array([42, 50, 53, 58, 64, 68, 73, 79, 82, 85, 90, 93])
# reshape(-1, 1): scikit-learn expects features as a 2D array (rows × columns)
X = study_hours.reshape(-1, 1)
y = exam_scores
# ── Split ─────────────────────────────────────────────────────────────
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42)
# ── Train ─────────────────────────────────────────────────────────────
model = LinearRegression()
model.fit(X_train, y_train)
# ── Evaluate ──────────────────────────────────────────────────────────
print(f"R² score: {model.score(X_test, y_test):.3f}")
# ── Predict ───────────────────────────────────────────────────────────
print(f"Predicted score for 5.5 hours: {model.predict([[5.5]])[0]:.1f}")
# ── Inspect what the model learned ────────────────────────────────────
print(f"Slope (m): {model.coef_[0]:.2f} — score gain per hour studied")
print(f"Intercept (b): {model.intercept_:.2f} — predicted score at 0 hours")
Add this block after the code above. Training points are shown in blue, test points in orange, and the fitted line in red:
import matplotlib.pyplot as plt
import numpy as np
# ── Visualise ─────────────────────────────────────────────────────────────
x_line = np.linspace(X.min(), X.max(), 100).reshape(-1, 1)
y_line = model.predict(x_line)
plt.figure(figsize=(8, 5))
plt.scatter(X_train, y_train, color='steelblue', label='Training data', zorder=3)
plt.scatter(X_test, y_test, color='orange', marker='D', label='Test data', zorder=3)
plt.plot(x_line, y_line, color='crimson', linewidth=2,
label=f'Fitted line (R²={model.score(X_test, y_test):.3f})')
plt.xlabel('Study hours')
plt.ylabel('Exam score')
plt.title('Linear Regression — Study Hours vs Exam Score')
plt.legend()
plt.tight_layout()
plt.show()
Orange diamonds are test points — they were never seen during training. The closer they sit to the line, the higher the R².
R² (R-squared) answers: "How much of the variation in exam scores does my model explain?"
| R² Value | Meaning |
|---|---|
| 1.0 | Perfect — model explains all variation |
| 0.85 | Strong — explains 85% of variation |
| 0.5 | Moderate — other factors matter too |
| 0.0 | Useless — no better than predicting the average every time |
| Negative | Worse than the average — model is actively wrong |
Suppose the code prints:
R² score: 0.981 Predicted score for 5.5 hours: 75.4 Slope (m): 6.48 Intercept (b): 39.71
Without running any code, answer three questions:
Slope 6.48: For every additional hour of study, the predicted exam score increases by 6.48 points.
Intercept 39.71: The model predicts a score of 39.71 if you studied 0 hours. Don't over-interpret intercepts — it's just where the line crosses the y-axis. It doesn't mean 0-hour students will actually score 39.
R² 0.981: Yes — excellent. The line explains 98.1% of the variation in exam scores. For a single-variable model, that's very strong.
NESA Linear regression only fits straight lines. But real relationships are often curved.
Consider: how many hours of sleep gives you the best exam performance? Too few and you're exhausted. Too many and you're groggy. The relationship peaks somewhere in the middle — that's a curve, not a line.
y = mx + b
Works when the relationship really is linear. Fails badly on curved data.
y = ax² + bx + c (degree 2)
Captures peaks, troughs, and non-linear patterns. Same OOP interface.
There's no separate "polynomial regression" class. Instead, you use PolynomialFeatures to transform your input first — then pass the transformed features into an ordinary LinearRegression.
For degree 2: it takes your feature [x] and expands it to [1, x, x²]. Now a linear model fitting those three columns is actually fitting a curve.
import numpy as np
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
# ── Data — sleep hours vs exam performance ────────────────────────────
sleep_hours = np.array([3, 4, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 10, 11])
performance = np.array([42, 50, 60, 65, 72, 78, 85, 87, 84, 80, 74, 62, 50])
X = sleep_hours.reshape(-1, 1)
y = performance
# ── Split ─────────────────────────────────────────────────────────────
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42)
# ── Transform features — expand [x] to [1, x, x²] ────────────────────
poly = PolynomialFeatures(degree=2) # degree=2 fits a parabola
X_train_poly = poly.fit_transform(X_train) # fit_transform on training data
X_test_poly = poly.transform(X_test) # transform (NOT fit_transform) on test data
# ── Train — ordinary LinearRegression on the expanded features ────────
model = LinearRegression()
model.fit(X_train_poly, y_train)
# ── Evaluate ──────────────────────────────────────────────────────────
print(f"R² score: {model.score(X_test_poly, y_test):.3f}")
# ── Predict — must transform new input with the same poly object ──────
new_hours = poly.transform([[7.5]])
print(f"Predicted performance for 7.5h sleep: {model.predict(new_hours)[0]:.1f}")
Add this block after the code above. It plots both a straight line and the polynomial curve on the same chart so you can see exactly why degree=2 wins here:
import matplotlib.pyplot as plt
import numpy as np
from sklearn.linear_model import LinearRegression
# ── Visualise — linear vs polynomial fit ─────────────────────────────────
x_range = np.linspace(X.min(), X.max(), 200).reshape(-1, 1)
# Polynomial curve (uses the poly and model objects from above)
y_curve = model.predict(poly.transform(x_range))
# Straight line for comparison — fit a plain linear model
lin = LinearRegression().fit(X_train, y_train)
y_line = lin.predict(x_range)
plt.figure(figsize=(8, 5))
plt.scatter(X_train, y_train, color='steelblue', label='Training data', zorder=3)
plt.scatter(X_test, y_test, color='orange', marker='D', label='Test data', zorder=3)
plt.plot(x_range, y_line, color='gray', linestyle='--', linewidth=1.5,
label=f'Linear fit (degree=1)')
plt.plot(x_range, y_curve, color='crimson', linewidth=2,
label=f'Polynomial fit (degree=2, R²={model.score(X_test_poly, y_test):.3f})')
plt.xlabel('Sleep hours')
plt.ylabel('Performance')
plt.title('Polynomial Regression — Sleep Hours vs Performance')
plt.legend()
plt.tight_layout()
plt.show()
The dashed grey line is what degree=1 would give you — flat and wrong. The red curve captures the peak around 7–8 hours then the drop-off.
Notice the code uses poly.fit_transform(X_train) but only poly.transform(X_test) — not fit_transform. The fit step "learns" how to expand the features from training data only. If you fit on the test set too, you leak information. Same rule applies when predicting new values.
Predict what would happen in each scenario, then reveal:
A straight line would fit the data poorly because the relationship is curved (peaks around 7–8 hours then drops). The R² score would be low — maybe 0.3 or less. The line would have to compromise between the rising section and the falling section, fitting neither well.
Overfitting. A degree-10 polynomial has so many terms that it can wiggle through every single training point perfectly — memorising the data rather than learning the pattern. The training R² would approach 1.0, but the test R² would be terrible. This is the same overfitting problem from linear regression, just more dramatic.
Rule of thumb: start with degree=2 or 3. Only go higher if you have strong reason and lots of data.
NESA Despite the name, logistic regression is a classification algorithm — not regression. It predicts the probability that something belongs to a category.
Instead of predicting "score = 74," it predicts "probability of passing = 0.87" — and then classifies based on that probability (usually: above 0.5 → class 1, below → class 0).
The key difference from linear regression: the output is always squashed between 0 and 1 using a function called the sigmoid.
Sigmoid output — maps any number to a probability between 0 and 1
Default threshold: > 0.5 → predict class 1
We have two features: hours studied and attendance percentage. The label is pass (1) or fail (0).
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# ── Data — [hours_studied, attendance_%] → pass/fail ─────────────────
X = np.array([
[1, 40], [1, 55], [2, 50], [2, 70], [3, 60],
[3, 75], [4, 65], [4, 85], [5, 70], [5, 90],
[6, 75], [6, 95], [7, 80], [8, 85], [8, 95],
])
y = np.array([0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1])
# ── Split ─────────────────────────────────────────────────────────────
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# ── Scale — logistic regression performs better with scaled features ──
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
# ── Train ─────────────────────────────────────────────────────────────
model = LogisticRegression()
model.fit(X_train_scaled, y_train)
# ── Evaluate ──────────────────────────────────────────────────────────
print(f"Accuracy: {model.score(X_test_scaled, y_test):.1%}")
# ── Predict — hard label and probability ─────────────────────────────
student = scaler.transform([[5, 72]]) # 5 hours study, 72% attendance
label = model.predict(student)[0]
proba = model.predict_proba(student)[0]
print(f"Prediction: {'Pass' if label == 1 else 'Fail'}")
print(f"P(Fail) = {proba[0]:.3f} P(Pass) = {proba[1]:.3f}")
Logistic regression has two features, so the chart is a 2D scatter plot. The shaded region shows the model's probability gradient — red = likely fail, blue = likely pass. The dashed black line is the decision boundary where P(pass) = 0.5:
import matplotlib.pyplot as plt
import numpy as np
# ── Visualise — scatter + decision boundary ───────────────────────────────
fails = X[y == 0]
passes = X[y == 1]
# Build a grid covering the feature space
h_range = np.linspace(X[:, 0].min() - 0.5, X[:, 0].max() + 0.5, 300)
a_range = np.linspace(X[:, 1].min() - 5, X[:, 1].max() + 5, 300)
hh, aa = np.meshgrid(h_range, a_range)
grid_scaled = scaler.transform(np.c_[hh.ravel(), aa.ravel()])
Z = model.predict_proba(grid_scaled)[:, 1].reshape(hh.shape)
plt.figure(figsize=(8, 6))
plt.contourf(hh, aa, Z, levels=20, cmap='RdBu', alpha=0.35)
plt.contour( hh, aa, Z, levels=[0.5], colors='black', linewidths=1.5, linestyles='--')
plt.colorbar(label='P(Pass)')
plt.scatter(fails[:, 0], fails[:, 1], color='crimson', label='Fail (0)', s=80, zorder=3)
plt.scatter(passes[:, 0], passes[:, 1], color='steelblue', label='Pass (1)', s=80, zorder=3)
plt.xlabel('Hours studied')
plt.ylabel('Attendance %')
plt.title('Logistic Regression — Decision Boundary')
plt.legend()
plt.tight_layout()
plt.show()
Anything above and to the right of the dashed line → the model predicts Pass. Everything below → Fail. Students close to the line are the uncertain cases where predict_proba returns a value near 0.5.
| Linear Regression | Logistic Regression | |
|---|---|---|
| Predicts | A number (continuous) | A probability → a class |
| Output range | Any value (e.g. −∞ to +∞) | Always 0.0 – 1.0 |
| Evaluation | R² score | Accuracy / predict_proba |
| Use when | Answer is a number | Answer is a category |
| OOP pattern | fit() → predict() → score() | fit() → predict() → score() |
A student writes this code and is confused about why the output looks wrong:
model = LogisticRegression()
model.fit(X_train, y_train)
new_student = [[5, 72]]
print(model.predict(new_student)) # prints [1]
print(model.predict_proba(new_student)) # error or unexpected values
What did they forget to do, and why does it matter for logistic regression?
They forgot to scale the features with StandardScaler.
Look at the two features:
hours_studied — values like 1, 2, 3 ... 8attendance_% — values like 40, 55, 72 ... 95Attendance numbers are roughly 10× bigger. Logistic regression learns by adjusting the weight of each feature — nudging them up or down based on how wrong the prediction was. When one feature's values are much larger, the model's weight-adjustment maths gets pushed around by the scale, not the actual importance of the feature. Attendance ends up dominating just because its numbers are bigger.
StandardScaler fixes this by rescaling both features to mean 0, standard deviation 1 — so they sit on the same playing field:
hours_studied → roughly −1.5 to +1.5attendance_% → roughly −1.5 to +1.5Now neither feature dominates just because of its units.
The fix — and a critical rule:
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train) # learns mean/std from training data, then scales it
X_test_s = scaler.transform(X_test) # uses the SAME mean/std — never re-learns on test data
You must always call transform() on test data, never fit_transform(). Re-fitting on test data would be "peeking" — the scaler would learn from test values and make the split dishonest, the same problem as training on test data.
NESA In Lesson 18 you learned that a neural network transforms data through layers of connected nodes. The MLPClassifier (Multi-Layer Perceptron) is scikit-learn's implementation of this — and it uses the exact same OOP interface you've been using all lesson.
The key hyperparameter is hidden_layer_sizes — a tuple where each number is the count of nodes in that hidden layer:
(4,) → one hidden layer with 4 nodes(8, 4) → two hidden layers: 8 nodes, then 4(16, 8, 4) → three hidden layers: gets deeper and more complexMore layers = more capacity to find complex patterns, but also more risk of overfitting on small datasets.
Using the same data as Section 4 — notice how similar the code is. This is the point of the OOP pattern.
import numpy as np
from sklearn.neural_network import MLPClassifier
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# Same data as Section 4
X = np.array([
[1,40],[1,55],[2,50],[2,70],[3,60],[3,75],[4,65],[4,85],
[5,70],[5,90],[6,75],[6,95],[7,80],[8,85],[8,95],
])
y = np.array([0,0,0,0,0,1,0,1,1,1,1,1,1,1,1])
# ── Split ─────────────────────────────────────────────────────────────
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# ── Scale — neural networks require scaled features ───────────────────
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
# ── Train ─────────────────────────────────────────────────────────────
mlp = MLPClassifier(
hidden_layer_sizes=(8, 4), # two hidden layers
max_iter=1000, # maximum training passes
random_state=42
)
mlp.fit(X_train_s, y_train)
# ── Evaluate ──────────────────────────────────────────────────────────
print(f"Accuracy: {mlp.score(X_test_s, y_test):.1%}")
# ── Predict with confidence ────────────────────────────────────────────
student = scaler.transform([[5, 72]])
label = mlp.predict(student)[0]
proba = mlp.predict_proba(student)[0]
print(f"Prediction: {'Pass' if label == 1 else 'Fail'}")
print(f"P(Fail)={proba[0]:.3f} P(Pass)={proba[1]:.3f}")
When to use a neural network vs logistic regression? For this small 15-row dataset, logistic regression will likely perform just as well (or better) than the neural network. Neural networks shine on large datasets and complex problems — images, audio, text. For small tabular data, simpler models often win because they're less likely to overfit.
Up next — Section 6 covers how to wrap all of this inside your own OOP class. That's where the four principles in the table above come together in code you design yourself.
NESA The syllabus asks you to build ML models "using an OOP." Using scikit-learn objects satisfies this — but designing your own wrapper class goes further and demonstrates that you understand OOP, not just the library.
The problem without a wrapper: every time you use a neural network you have to remember to scale the data, use transform() (not fit_transform()) on test data, and repeat those three steps every time. Forget one step and the model gives wrong answers silently.
Solution: wrap the model + scaler inside one class. The caller never thinks about scaling — they just call train() and predict().
Each part maps directly to an OOP concept:
from sklearn.preprocessing import StandardScaler
from sklearn.neural_network import MLPClassifier
from sklearn.linear_model import LogisticRegression
class MLPipeline:
def __init__(self, model):
# Encapsulation — the object owns its scaler and model.
# The caller doesn't touch these directly.
self._model = model
self._scaler = StandardScaler()
self._trained = False
def train(self, X_train, y_train):
# Abstraction — caller just says "train".
# Scaling + fitting happens inside; the caller doesn't know or care.
X_scaled = self._scaler.fit_transform(X_train)
self._model.fit(X_scaled, y_train)
self._trained = True
def predict(self, X):
# Message passing — pipeline.predict(X) sends a message to the object.
# The object acts on its own internal state (_scaler, _model).
X_scaled = self._scaler.transform(X) # transform only, NOT fit_transform
return self._model.predict(X_scaled)
def evaluate(self, X_test, y_test):
X_scaled = self._scaler.transform(X_test)
return self._model.score(X_scaled, y_test)
def is_trained(self):
return self._trained
This is generalisation in action. The MLPipeline accepts any scikit-learn model because they all share the same interface. You swap the model; none of the surrounding code changes:
import numpy as np
from sklearn.model_selection import train_test_split
X = np.array([
[1,40],[1,55],[2,50],[2,70],[3,60],[3,75],[4,65],[4,85],
[5,70],[5,90],[6,75],[6,95],[7,80],[8,85],[8,95],
])
y = np.array([0,0,0,0,0,1,0,1,1,1,1,1,1,1,1])
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# ── Try a neural network ──────────────────────────────────────────────────
nn_pipeline = MLPipeline(MLPClassifier(hidden_layer_sizes=(8, 4), max_iter=1000, random_state=42))
nn_pipeline.train(X_train, y_train)
print(f"Neural network accuracy : {nn_pipeline.evaluate(X_test, y_test):.1%}")
# ── Swap in logistic regression — same code, different model ─────────────
lr_pipeline = MLPipeline(LogisticRegression())
lr_pipeline.train(X_train, y_train)
print(f"Logistic regression acc : {lr_pipeline.evaluate(X_test, y_test):.1%}")
# ── Predict with any pipeline — same call ────────────────────────────────
new_student = np.array([[5, 72]])
print(f"NN prediction : {'Pass' if nn_pipeline.predict(new_student)[0] == 1 else 'Fail'}")
print(f"LogReg prediction : {'Pass' if lr_pipeline.predict(new_student)[0] == 1 else 'Fail'}")
The key insight: nn_pipeline.train() and lr_pipeline.train() are identical calls. The pipeline doesn't care which model is inside. That's because both MLPClassifier and LogisticRegression respond to the same messages — fit(), predict(), score(). This is generalisation.
Add a method called compare(models, X_train, X_test, y_train, y_test) that takes a list of models, wraps each one in an MLPipeline, trains it, and prints each model's accuracy.
def compare(models, X_train, X_test, y_train, y_test):
for model in models:
pipeline = MLPipeline(model)
pipeline.train(X_train, y_train)
acc = pipeline.evaluate(X_test, y_test)
print(f"{type(model).__name__:30} {acc:.1%}")
# Usage
compare(
[LogisticRegression(), MLPClassifier(hidden_layer_sizes=(8, 4), max_iter=1000, random_state=42)],
X_train, X_test, y_train, y_test
)
type(model).__name__ returns the class name as a string — so it prints "LogisticRegression" or "MLPClassifier" automatically regardless of which model you pass in. This only works because all models share the same OOP interface.
Fits a straight line. Predicts a continuous number. Evaluated with R².
Use when: relationship is roughly linear.
Fits a curve using PolynomialFeatures + LinearRegression. Still predicts a number.
Use when: relationship has a peak, trough, or bend.
Predicts a probability → a class. Uses sigmoid to keep output 0–1.
Use when: answer is a category (pass/fail, yes/no).
Why scikit-learn models are OOP objects and what fit(), predict(), score() each do
Why you must use a train/test split and what overfitting looks like
How to build a linear regression model and what R² means
How PolynomialFeatures transforms inputs so a linear model can fit a curve
How logistic regression differs from linear regression — probability output, sigmoid, classification
How to build an MLPClassifier and what hidden_layer_sizes controls
How to wrap a model in your own OOP class
Automation spectrum, AI vs ML, types of learning, key algorithms, applications
OOP ML models, train/test split, linear regression, polynomial regression, logistic regression, neural networks
Bias, employment, disability, economy, psychological effects, cultural protocols. Full HSC exam question practice.
Task 1 — Run the logistic regression code
Install scikit-learn if needed (pip install scikit-learn), then run the Section 4 logistic regression example.
1. What accuracy does it print?
2. Change the student being predicted to [3, 55] (3 hours study, 55% attendance). What does the model predict, and with what confidence?
3. Try [7, 90]. Does the result make sense?
Task 2 — Design question (no code needed)
A music streaming app wants to predict a user's satisfaction score (1–10) after a listening session based on: session length (minutes), number of songs skipped, and whether a recommended playlist was played.
1. Is this linear regression, polynomial regression, logistic regression, or a neural network problem? Why?
2. What would overfitting look like for this model?
3. What would you need to know before choosing degree=3 polynomial instead of degree=1?
Bring both tasks to next lesson. For Task 1, screenshots of the output are fine.