← Retour à l'épreuve

Canada CAIO 2025 · Correction IOAI Training

AI Fundamentals Challenge

Cette page donne toute la méthode. Lisez-la une fois que vous avez cherché.

Métrique
points (100 max)
Évaluée sur
written answers
Notre score
all 35 answers derived, 22 checked
Niveau attendu
100 points (25 MCQ x 2 + 10 open x 5)

Niveau attendu atteint

Nous avons résolu l'épreuve nous-mêmes : téléchargé les données, mené les expériences et mesuré le score ci-dessous. L'énoncé et les données appartiennent à leurs organisateurs. Cette correction est rédigée en anglais.

The problem in brief

This is the first round of the 2025 Canadian AI Olympiad national qualifier: a 35-question objective test. Questions 1 to 25 are multiple choice (one correct option out of five) on NumPy and pandas behaviour, the calculus of linear regression, optimisation, the bias-variance trade-off, classical models (trees, SVMs, naive Bayes, K-means) and graph search. Questions 26 to 35 are short "fill in the blank" or "compute this number" items: small pandas pipelines, a cost function, an information gain, a naive Bayes posterior, two gradient loops and a crawling question. Nothing here is hard on its own; the difficulty is to be fast and exact on 35 different topics.

How you are scored

Each multiple-choice question is worth 2 points and each open question 5 points, for a total of

25×2+10×5=100 points.25 \times 2 + 10 \times 5 = 100 \text{ points.}

Numeric answers are typed into a box with a character limit (for example 3 to 4 characters for "two decimal places"), so the rounding format matters. No official answer key was published with the paper; every answer below is derived from scratch, and every computable one is checked independently in solution.py (finite differences for gradients, exact fractions for probabilities, running the code for the pandas questions).

Question by question

Part A: code and arrays (Q1, Q2, Q7, Q14, Q25)

Q1. np.dot(A, B.T) multiplies each row of AA by each row of BB (the columns of B⊤B^\top are the rows of BB), so Cij=ai⋅bjC_{ij} = a_i \cdot b_j:

C00=1⋅1+2⋅0+3⋅5=16,C01=2+2+18=22,C10=0+0+20=20,C11=0+1+24=25.C_{00} = 1\cdot1+2\cdot0+3\cdot5 = 16,\quad C_{01} = 2+2+18 = 22,\quad C_{10} = 0+0+20 = 20,\quad C_{11} = 0+1+24 = 25 .

C[:2, :2] is the top-left block: [[16 22] [20 25]]. The trap option [[16 20] [22 25]] is its transpose, the block of B @ A.T: what you get by mixing up which matrix provides the rows.

Q2. df['B'] > 20 keeps rows 2, 3, 4; the column list keeps A and C. iloc[1, 1] is the second kept row (original row 3) and the second kept column (C), whose value is 2. loc filters by labels, iloc then counts positions in the filtered result.

Q7. i takes the values 1,3,9,…,3k1, 3, 9, \dots, 3^k and the loop stops once 3k>n3^k > n, so it runs ⌊log⁡3n⌋+1\lfloor \log_3 n \rfloor + 1 times: O(log⁡3N)O(\log_3 N). (Mathematically O(log⁡3N)=O(log⁡2N)O(\log_3 N) = O(\log_2 N) since the bases differ by a constant factor, but the option matching the code is log⁡3\log_3.)

Q14. fillna(0) returns a new series used only to build C; column B itself still contains NaN. Row 1 is therefore A = 2.0, B = NaN, C = 2 + 0 = 2.0 (all floats because the column with NaN is float64): A 2.0, B NaN, C 2.0.

Q25. Vectorised NumPy runs the loop in compiled code over whole arrays (with SIMD instructions and no Python interpreter overhead per element): vectorization allows parallel operations on entire data. It does not change the maths, the learning rate or the data, and NumPy does not move anything to a GPU.

Part B: linear regression and gradient descent (Q3, Q4, Q5, Q6, Q15, Q28, Q33, Q34)

Q3. Differentiate each squared residual with the chain rule. With ri=yi−(wxi+b)r_i = y_i - (w x_i + b), ∂ri/∂w=−xi\partial r_i / \partial w = -x_i, so

∂L∂w=1n∑i2ri⋅(−xi)=−2n∑ixi(yi−(wxi+b)).\frac{\partial L}{\partial w} = \frac{1}{n}\sum_i 2 r_i \cdot(-x_i) = -\frac{2}{n}\sum_i x_i\big(y_i - (w x_i + b)\big).

Two traps: forgetting the factor xix_i (that is ∂L/∂b\partial L/\partial b) and forgetting the minus sign.

Q4. (X⊤X)−1(X^\top X)^{-1} must exist: X⊤XX^\top X is invertible, which means the columns of XX are linearly independent (no duplicated or perfectly collinear feature, and at least as many samples as features). yy can be anything and XX is usually not even square, so "symmetric" or "orthogonal" make no sense here.

Q5. In matrix form L(w)=1n∥Xw−y∥2L(w) = \frac{1}{n}\lVert Xw - y\rVert^2, whose gradient is 2nX⊤(Xw−y)\frac{2}{n}X^\top(Xw - y). Gradient descent moves against it: w:=w−2αnX⊤(Xw−y)w := w - \frac{2\alpha}{n}X^\top(Xw-y). A quick shape check eliminates the others: with XX of shape (n,d)(n, d), X⊤(Xw−y)X^\top(Xw-y) has shape (d,)(d,) like ww, while (Xw−y)X⊤(Xw-y)X^\top does not even multiply.

Q6. When one feature is in thousands and another in fractions, the loss surface is a long thin valley: one learning rate is too big along one axis and too small along the other. Standardising puts features on the same scale and centres them at 0, which avoids huge gradient steps in one direction. It does not decorrelate features (that would be PCA whitening) and trees do not care about scaling.

Q15. With too large a step, each update overshoots the minimum, landing higher on the other side: the cost oscillates or diverges.

Q28. With f(x)=6x+4f(x) = 6x + 4 the predictions are 10,16,22,28,3410, 16, 22, 28, 34 and the errors f(x)−yf(x) - y are 0,−1,−3,−4,−10, -1, -3, -4, -1:

J=12⋅5(0+1+9+16+1)=2710=2.7.J = \frac{1}{2\cdot 5}(0 + 1 + 9 + 16 + 1) = \frac{27}{10} = \boxed{2.7}.

Q33. Inside the loop:

f_wb = w * x[i] + b
cost = (f_wb - y[i]) ** 2
cost_sum = cost_sum + cost

On the Q28 data this function returns exactly 2.7, a nice cross-check.

Q34. Same structure, but the prediction goes through the sigmoid and the gradient accumulates the error times each feature:

for i in range(m):
    f_wb_i = 1 / (1 + np.exp(-(np.dot(X[i], w) + b)))
    err_i = f_wb_i - y[i]
    for j in range(n):
        dj_dw[j] = dj_dw[j] + err_i * X[i, j]
    dj_db = dj_db + err_i
dj_dw = dj_dw / m
dj_db = dj_db / m

The remarkable fact this question hides: for logistic regression with cross-entropy, the gradient has exactly the same form as for linear regression with MSE, 1m∑i(f(x(i))−y(i))xj(i)\frac{1}{m}\sum_i (f(x^{(i)}) - y^{(i)})x^{(i)}_j; only ff changes. solution.py checks the loop against the vectorised formula 1mX⊤(σ(Xw+b)−y)\frac{1}{m}X^\top(\sigma(Xw+b) - y).

Part C: generalisation (Q9, Q10, Q16, Q17, Q18, Q21, Q22, Q24)

These eight questions are one idea seen from several angles. The test error of a model splits (for squared loss) into

E[(y−f^(x))2]=bias2+variance+noise.\mathbb{E}\big[(y - \hat f(x))^2\big] = \text{bias}^2 + \text{variance} + \text{noise}.

A rigid model has high bias (wrong on average, even on training data); a very flexible one has high variance (it changes a lot when the training set changes, so it fits noise).

questionsituationanswer
Q9huge regularisation λ\lambdaweights forced near 0: underfit
Q16the trade-off in one sentencereducing bias often increases variance ("always" in another option is too strong)
Q17train and validation errors meet at a high valuemore data will not help: underfitting
Q18adding λ2∥w∥2\frac{\lambda}{2}\lVert w\rVert^2shrinks weights: increases bias, decreases variance (L2 never sets weights exactly to 0, L1 does)
Q21deep tree, few samples per leafeach leaf memorises a handful of points: overfitting
Q22high variance looks likelow training error, high test error
Q24Jcv≫JtrainJ_{cv} \gg J_{train}high variance
Q10smaller XGBoost learning rate η\etaeach tree corrects less, so you need more trees but generalise better (shrinkage is a regulariser)

Part D: classical models (Q8, Q13, Q19, Q20, Q23, Q30, Q31)

Q8. g′(z)=g(z)(1−g(z))g'(z) = g(z)(1 - g(z)) is at most 0.250.25 (at z=0z=0) and decays like e−∣z∣e^{-|z|}: the gradient approaches zero (vanishes). This is why deep sigmoid networks trained slowly, and why ReLU became standard.

The sigmoid and its derivative: the derivative peaks at 0.25 at z = 0 and is almost zero beyond |z| = 5, the saturated zones shaded in grey.

Q13. Assignment: the boundary between centroids 2 and 12 is their midpoint 7, so {1,2,3}\{1,2,3\} go to C1C_1 and {10,12,13}\{10,12,13\} to C2C_2. Update: C1=6/3=2C_1 = 6/3 = 2 and C2=35/3≈11.67C_2 = 35/3 \approx 11.67: C1=2C_1 = 2, C2=11.67C_2 = 11.67 (the option printed with a stray "A." prefix).

One K-means iteration on the line: the three left points stay with centroid 2, the three right points pull centroid 12 to 11.67.

Q19. We want the statement that is false as a limitation. SVMs maximise the margin, which controls capacity independently of the dimension; that is exactly why linear SVMs work so well on text, where features vastly outnumber samples. So "SVMs suffer from severe overfitting when the feature dimension is higher than the number of samples" is not a real limitation. The other four are (scaling sensitivity, tuning of CC and kernel parameters, no native probabilities, training cost growing at least quadratically with nn).

Q20. A kernel k(x,x′)=φ(x)⋅φ(x′)k(x, x') = \varphi(x)\cdot\varphi(x') gives dot products in feature space without computing the mapping φ\varphi. The boundary is linear in feature space but nonlinear in input space (the reverse of one distractor), and the trick lives inside the dual formulation.

Q23. Naive Bayes is a generative model: it estimates the likelihood P(xi∣Ck)P(x_i \mid C_k) of each feature given the class, then flips it with Bayes' rule. Logistic regression is discriminative: what it estimates is P(Ck∣x)P(C_k \mid x), never how the features are distributed. Plugging such a number where Bayes' rule expects P(xi∣Ck)P(x_i \mid C_k) means the factors are no longer likelihoods, so you lose likelihood estimation (the generative view of the data). This question is the least clear-cut of the paper; "conditional independence" is the tempting distractor, but that assumption is about how the factors are combined (by a product), which you keep.

Q30. Entropy in bits, H(p)=−∑cpclog⁡2pcH(p) = -\sum_c p_c \log_2 p_c:

H(parent)=H(0.6,0.4)=0.6(0.737)+0.4(1.322)=0.971H(\text{parent}) = H(0.6, 0.4) = 0.6(0.737) + 0.4(1.322) = 0.971 H(left)=H(0.8,0.2)=0.8(0.322)+0.2(2.322)=0.722,H(right)=H(0.4,0.6)=0.971.H(\text{left}) = H(0.8, 0.2) = 0.8(0.322) + 0.2(2.322) = 0.722,\qquad H(\text{right}) = H(0.4, 0.6) = 0.971 .

Both branches hold 5 of the 10 samples, so

IG=0.971−(510 0.722+510 0.971)=0.971−0.846=0.1245≈0.12.IG = 0.971 - \left(\tfrac{5}{10}\,0.722 + \tfrac{5}{10}\,0.971\right) = 0.971 - 0.846 = 0.1245 \approx \boxed{0.12}.

Careful with rounding: rounding the intermediate entropies to 0.97, 0.72, 0.97 first gives 0.1250.125, which then rounds to 0.13. Keep three decimals until the end.

Q31. With Laplace smoothing α=1\alpha=1 and a vocabulary of V=3V = 3 words, P(w∣c)=count(w,c)+1Nc+3P(w \mid c) = \frac{\text{count}(w, c) + 1}{N_c + 3}, so the denominators are 18:

P(E) P(economy∣E) P(goal∣E)=0.5⋅918⋅518,P(S) P(economy∣S) P(goal∣S)=0.5⋅318⋅718.P(E)\,P(\text{economy}\mid E)\,P(\text{goal}\mid E) = 0.5 \cdot \tfrac{9}{18}\cdot\tfrac{5}{18},\qquad P(S)\,P(\text{economy}\mid S)\,P(\text{goal}\mid S) = 0.5 \cdot \tfrac{3}{18}\cdot\tfrac{7}{18}.

Normalising, the common factors cancel:

P(E∣doc)=9⋅59⋅5+3⋅7=4566=1522≈0.68.P(E \mid \text{doc}) = \frac{9\cdot 5}{9\cdot5 + 3\cdot 7} = \frac{45}{66} = \frac{15}{22} \approx \boxed{0.68}.

Part E: search (Q11, Q12, Q35)

Q11. With w>1w > 1 the heuristic is inflated, so the search is greedier: it expands far fewer nodes, but an inflated heuristic is no longer admissible. Faster but possibly sub-optimal (the cost found is at most ww times the optimum when hh is admissible).

Q12. Uniform Cost Search (Dijkstra) stops as soon as the goal is popped from the priority queue, which is correct only if path costs can never decrease later. A negative edge breaks that: a cheaper path through a not-yet-expanded node may still exist. So it terminates early with an incorrect cost. (An infinite loop needs a negative cycle and no visited set: possible, but not what happens in general.)

Q35. BFS with a visited set. All links have the same "cost", and we want every page, not a cheapest path, so UCS and A* bring nothing (no edge weights, no goal, no heuristic). BFS explores the site level by level from the home page, which reaches the important, shallow pages first and is easy to cap at a depth; the visited set is essential because the web graph has cycles (every page links back to the home page). DFS with a visited set would also visit everything, but it dives into deep chains of links (calendars, pagination) and needs care with recursion depth, so BFS is the safer default.

Part F: pandas and scikit-learn fill-ins (Q26, Q27, Q29, Q32)

Q26.

df = pd.read_csv('temperature.csv')
df['temp_f'] = np.array(df['temp_c']) * 9 / 5 + 32
plt.plot(df['day'], df['temp_f'], marker='o')
plt.xlabel('Day'); plt.ylabel('Temperature (°F)'); plt.title('Daily temperature')
plt.show()

(Values: 53.6, 57.2, 59.0, 51.8, 55.4 °F.)

Q27. In order: fillna(0), mean(), fillna(mean_discount), df['units_sold'] * df['price'] * (1 - df['discount']), groupby, sort_values. Order matters: the mean discount is computed before filling (pandas skips NaN in mean), otherwise you would average the filled values.

Q29.

nan_counts = df.isna().sum()
cols_sorted = nan_counts.sort_values(ascending=False).index.tolist()
pct = df.isna().mean().mul(100).sort_values(ascending=False)
pct = pct[pct >= 20]
plt.bar(pct.index, pct.values); plt.xticks(rotation=90)
plt.xlabel('column'); plt.ylabel('% NaN'); plt.show()

isna().mean() is the fraction of missing values per column, because True counts as 1.

Q32. In order: LogisticRegression, accuracy_score, LogisticRegression, fit, predict, accuracy_score.

The key idea

The paper looks like 35 unrelated facts, but most of it rests on three reflexes:

  1. Derive, do not recall. Q3, Q5, Q28, Q30, Q31, Q33 and Q34 are a single chain rule or a single formula applied carefully. Writing the residual rir_i and its derivative takes ten seconds and removes every sign trap; a shape check (X⊤X^\top of shape (d,n)(d, n) times a vector of shape (n,)(n,)) eliminates impossible options in Q5 instantly.
  2. One picture for generalisation. Training error measures bias, the gap between validation and training error measures variance. Q9, Q10, Q16, Q17, Q18, Q21, Q22 and Q24 are all read off that picture.
  3. Know what each method assumes. UCS assumes non-negative costs (Q12), A* optimality assumes an admissible heuristic (Q11), the normal equation assumes independent columns (Q4), naive Bayes estimates likelihoods (Q23). A question asking "what happens if..." is almost always "which assumption breaks".

The full solution

solution.py contains an assert for every computable answer: the matrix block of Q1, the pandas outputs of Q2, Q14, Q26, Q27, Q29, finite-difference checks of the gradients of Q3 and Q5, an iteration counter for Q7, the K-means step of Q13, exact fractions for Q28 and Q31, the entropy computation of Q30, a small logistic regression pipeline for Q32 and the loop implementations of Q33 and Q34 compared with their vectorised versions. It also draws the two figures.

Results

QanswerQanswer
1[[16 22] [20 25]]19overfitting when d>nd > n (not a real limitation)
2220dot products in feature space without the mapping
3−2n∑ixi(yi−(wxi+b))-\frac{2}{n}\sum_i x_i(y_i-(wx_i+b))21deep tree with few samples per leaf
4X⊤XX^\top X invertible22low training error, high test error
5w:=w−2αnX⊤(Xw−y)w := w - \frac{2\alpha}{n}X^\top(Xw-y)23likelihood estimation (least certain)
6centres features, avoids large gradient steps24high variance
7O(log⁡3N)O(\log_3 N)25parallel operations on entire data
8approaches zero (vanishes)26read_csv, * 9 / 5 + 32, plot
9underfit27fillna(0), mean(), fillna(...), product, groupby, sort_values
10more trees, better generalisation282.7
11faster, possibly sub-optimal29isna().sum(), sort, isna().mean()*100 >= 20, bar plot
12terminates early with incorrect cost300.12
13C1=2C_1 = 2, C2=11.67C_2 = 11.67310.68
14A 2.0, B NaN, C 2.032LogisticRegression, accuracy_score, fit, predict
15oscillates or diverges33f_wb, squared error, accumulate
16reducing bias often increases variance34sigmoid prediction, error, accumulate, divide by mm
17underfitting35BFS with a visited set
18increases bias, decreases variance

Typical mistakes worth naming: the transposed matrix in Q1 (forgetting .T), thinking fillna modifies the column in place in Q14, rounding intermediate entropies in Q30 (gives 0.13), and forgetting that Laplace smoothing adds the vocabulary size to the denominator in Q31.

What to remember

  • Check shapes and signs before picking an option. Most wrong options in gradient questions fail a shape check or a sign check.
  • Training error tells you about bias, the train/validation gap tells you about variance. Regularisation, shallower trees and smaller learning rates trade the second for the first.
  • Keep full precision until the last step, then round in the format requested: a two-decimal answer box punishes early rounding.
  • pandas returns new objects (fillna, sort_values) unless you assign them back; loc is by label, iloc by position in the current result.
  • Every algorithm comes with an assumption (non-negative costs, admissible heuristic, invertible X⊤XX^\top X): "what happens if" questions are about which one breaks.
Code complet (solution.py)
"""CAIO 2025 National Qualifier Round 1: independent checks of every computable answer.

Run: python solution.py   (writes figures/ next to this file)
"""
import io, os, math
from fractions import Fraction
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

HERE = os.path.dirname(os.path.abspath(__file__))
FIG = os.path.join(HERE, "figures"); os.makedirs(FIG, exist_ok=True)

# Q1: C = A @ B.T, print the top-left 2x2 block
A = np.array([[1, 2, 3], [0, 1, 4], [5, 6, 0]])
B = np.array([[1, 0, 5], [2, 1, 6], [3, 4, 0]])
C = np.dot(A, B.T)
assert C[:2, :2].tolist() == [[16, 22], [20, 25]]

# Q2: boolean row filter + column list, then positional indexing
df = pd.DataFrame({"A": [1, 2, 3, 4, 5], "B": [10, 20, 30, 40, 50], "C": [5, 4, 3, 2, 1]})
assert df.loc[df["B"] > 20, ["A", "C"]].iloc[1, 1] == 2

# Q3: dL/dw by finite differences on random data
rng = np.random.default_rng(0)
x, y = rng.normal(size=20), rng.normal(size=20)
w, b, n = 0.7, -0.3, 20
L = lambda w, b: np.mean((y - (w * x + b)) ** 2)
num = (L(w + 1e-6, b) - L(w - 1e-6, b)) / 2e-6
assert abs(num - (-2 / n) * np.sum(x * (y - (w * x + b)))) < 1e-6

# Q5: vectorised gradient of the MSE equals (2/n) X^T (Xw - y)
X = rng.normal(size=(20, 3)); yv = rng.normal(size=20); wv = rng.normal(size=3)
J = lambda wv: np.mean((X @ wv - yv) ** 2)
g_num = np.array([(J(wv + e) - J(wv - e)) / 2e-6 for e in np.eye(3) * 1e-6])
assert np.allclose(g_num, 2 / 20 * X.T @ (X @ wv - yv), atol=1e-6)

# Q7: number of loop iterations grows like log_3(n)
def iterations(n):
    i, k = 1, 0
    while i <= n:
        i *= 3; k += 1
    return k
for n in [1, 10, 100, 3**10, 10**9]:
    assert iterations(n) == math.floor(math.log(n, 3) + 1e-9) + 1

# Q13: one K-means iteration
pts = np.array([1, 2, 3, 10, 12, 13], float); c = np.array([2.0, 12.0])
lab = np.argmin(np.abs(pts[:, None] - c[None, :]), axis=1)
c_new = np.array([pts[lab == k].mean() for k in range(2)])
assert np.allclose(c_new, [2, 35 / 3])

# Q14: NaN propagates in the column itself, but C was built from the filled copy
df = pd.DataFrame({"A": [1, 2, 3], "B": [4, np.nan, 6]})
df["C"] = df["A"] + df["B"].fillna(0)
row = df.iloc[1]
assert row["A"] == 2 and math.isnan(row["B"]) and row["C"] == 2

# Q26: temperature conversion
t = pd.read_csv(io.StringIO("day,temp_c\n1,12\n2,14\n3,15\n4,11\n5,13"))
t["temp_f"] = np.array(t["temp_c"]) * 9 / 5 + 32
assert t["temp_f"].tolist() == [53.6, 57.2, 59.0, 51.8, 55.4]

# Q27: cleaning pipeline on a toy sales table
s = pd.DataFrame({"store_id": [1, 1, 2, 2], "units_sold": [2, np.nan, 5, 1],
                  "price": [10.0, 3.0, 2.0, 4.0], "discount": [0.1, 0.5, np.nan, 0.0]})
s["units_sold"] = s["units_sold"].fillna(0)
mean_discount = s["discount"].mean()
s["discount"] = s["discount"].fillna(mean_discount)
s["revenue"] = s["units_sold"] * s["price"] * (1 - s["discount"])
rev = s.groupby("store_id")["revenue"].sum().sort_values(ascending=False)
assert rev.index.tolist() == [1, 2] and np.isclose(rev[1], 18) and np.isclose(rev[2], 12)

# Q28: cost J(w=6, b=4) on 5 points, J = 1/(2m) sum (f(x)-y)^2
pts28 = [(1, 10), (2, 17), (3, 25), (4, 32), (5, 35)]
J28 = Fraction(sum((6 * a + 4 - yy) ** 2 for a, yy in pts28), 2 * 5)
assert J28 == Fraction(27, 10)

# Q29: NaN audit on a synthetic 3000 x 200 frame
d = pd.DataFrame(rng.normal(size=(3000, 200)), columns=[f"c{i}" for i in range(200)])
for i, frac in enumerate([0.5, 0.3, 0.2, 0.19]):
    d.iloc[: int(frac * 3000), i] = np.nan
nan_counts = d.isna().sum()
cols_sorted = nan_counts.sort_values(ascending=False).index.tolist()
pct = d.isna().mean().mul(100).sort_values(ascending=False)
pct20 = pct[pct >= 20]
assert cols_sorted[:4] == ["c0", "c1", "c2", "c3"] and pct20.index.tolist() == ["c0", "c1", "c2"]

# Q30: information gain, exact entropies
H = lambda *p: -sum(q * math.log2(q) for q in p if q > 0)
IG = H(.6, .4) - (0.5 * H(.8, .2) + 0.5 * H(.4, .6))
assert round(IG, 2) == 0.12

# Q31: multinomial naive Bayes with Laplace smoothing
V = 3
pE = Fraction(1, 2) * Fraction(8 + 1, 15 + V) * Fraction(4 + 1, 15 + V)
pS = Fraction(1, 2) * Fraction(2 + 1, 15 + V) * Fraction(6 + 1, 15 + V)
post = pE / (pE + pS)
assert post == Fraction(15, 22) and round(float(post), 2) == 0.68

# Q32: logistic regression pipeline on synthetic students
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
hours = rng.uniform(0, 10, 200); score = rng.uniform(0, 100, 200)
st = pd.DataFrame({"hours": hours, "score": score,
                   "passed": (hours * 8 + score + rng.normal(0, 10, 200) > 90).astype(int)})
Xtr, Xte, ytr, yte = train_test_split(st[["hours", "score"]], st["passed"], test_size=0.2, random_state=42)
model = LogisticRegression(); model.fit(Xtr, ytr)
acc = accuracy_score(yte, model.predict(Xte)); assert acc > 0.8

# Q33: loop cost == vectorised cost
def compute_cost(x, y, w, b):
    m = x.shape[0]; cost_sum = 0
    for i in range(m):
        f_wb = w * x[i] + b
        cost = (f_wb - y[i]) ** 2
        cost_sum = cost_sum + cost
    return (1 / (2 * m)) * cost_sum
xa = np.array([1, 2, 3, 4, 5.]); ya = np.array([10, 17, 25, 32, 35.])
assert np.isclose(compute_cost(xa, ya, 6, 4), 2.7)

# Q34: loop gradient == vectorised gradient of the logistic cost
sig = lambda z: 1 / (1 + np.exp(-z))
def compute_gradient_logistic(X, y, w, b):
    m, n = X.shape; dj_dw = np.zeros((n,)); dj_db = 0.
    for i in range(m):
        f_wb_i = sig(np.dot(X[i], w) + b)
        err_i = f_wb_i - y[i]
        for j in range(n):
            dj_dw[j] = dj_dw[j] + err_i * X[i, j]
        dj_db = dj_db + err_i
    dj_dw = dj_dw / m; dj_db = dj_db / m
    return dj_db, dj_dw
Xl = rng.normal(size=(30, 4)); yl = (rng.random(30) > .5).astype(float); wl = rng.normal(size=4)
db, dw = compute_gradient_logistic(Xl, yl, wl, 0.2)
p = sig(Xl @ wl + 0.2)
assert np.allclose(dw, Xl.T @ (p - yl) / 30) and np.isclose(db, np.mean(p - yl))
print("All computable answers verified.")

# ---------- figures ----------
plt.rcParams.update({"font.size": 12, "axes.spines.top": False, "axes.spines.right": False})
z = np.linspace(-10, 10, 400)
fig, ax = plt.subplots(figsize=(8, 4.2))
ax.plot(z, sig(z), lw=2.5, label=r"sigmoid $g(z)$")
ax.plot(z, sig(z) * (1 - sig(z)), lw=2.5, label=r"gradient $g'(z)=g(z)(1-g(z))$")
ax.axvspan(-10, -5, color="0.9"); ax.axvspan(5, 10, color="0.9")
ax.text(-7.5, 0.55, "saturated:\ngradient < 0.007", ha="center")
ax.text(7.5, 0.55, "saturated:\ngradient < 0.007", ha="center")
ax.set_xlabel("pre-activation z"); ax.set_ylabel("value"); ax.legend(loc="upper left")
ax.set_title("Q8: the sigmoid gradient peaks at 0.25 and vanishes for large |z|")
fig.tight_layout(); fig.savefig(os.path.join(FIG, "sigmoid_gradient.png"), dpi=150); plt.close(fig)

fig, ax = plt.subplots(figsize=(8, 2.6))
colors = np.where(lab == 0, "tab:blue", "tab:orange")
ax.scatter(pts, np.zeros_like(pts), s=120, c=colors, zorder=3)
for k, (old, new) in enumerate(zip(c, c_new)):
    col = ["tab:blue", "tab:orange"][k]
    ax.scatter([old], [0.5], marker="x", s=140, c=col)
    ax.scatter([new], [-0.5], marker="*", s=260, c=col)
    ax.annotate("", xy=(new, -0.42), xytext=(old, 0.42), arrowprops=dict(arrowstyle="->", color=col))
ax.axvline(7, ls="--", c="0.5"); ax.text(7.1, 0.75, "midpoint 7: assignment boundary", color="0.4", fontsize=10)
ax.set_yticks([0.5, 0, -0.5]); ax.set_yticklabels(["old centroids", "points", "new centroids"])
ax.set_ylim(-1, 1); ax.set_xlabel("x")
ax.set_title("Q13: one K-means step, centroids 2 and 12 become 2 and 11.67")
fig.tight_layout(); fig.savefig(os.path.join(FIG, "kmeans_step.png"), dpi=150); plt.close(fig)