Level 4 — ApplicationPython & Scientific Computing

Python & Scientific Computing

60 minutes60 marksprintable — key stays hidden on paper

Level: 4 — Application (novel problems, no hints) Time limit: 60 minutes Total marks: 60

Answer all questions. Show reasoning, code, and outputs where asked. Assume import numpy as np, import pandas as pd are available unless stated otherwise.


Question 1 — Broadcasting & Vectorization (12 marks)

You are given a dataset of M points in R^d stored in array X of shape (M, d) and N centroids in array C of shape (N, d).

(a) Write a single vectorized NumPy expression (no Python loops) that produces a matrix D of shape (M, N) where D[i, j] is the squared Euclidean distance between point i and centroid j. Explain the broadcasting shapes involved. (6)

(b) Given X has shape (1000, 3) and C has shape (8, 3), state the shape of every intermediate array in your expression, and the final shape. (3)

(c) Using D, write one line that returns the index of the nearest centroid for each of the M points. (3)


Question 2 — Comprehensions & Generators (12 marks)

(a) Using a single dict comprehension, build a dictionary mapping each word in the list words = ["ml", "ai", "data", "ml", "ai", "ml"] to its frequency count. You may not import Counter. (4)

(b) Explain the difference in memory behaviour between

a = [x**2 for x in range(10**7)]
b = (x**2 for x in range(10**7))

and give one situation where b is strictly preferable. (4)

(c) Write a generator function batched(iterable, n) that yields successive lists of length n (the last batch may be shorter). Show the output of list(batched(range(7), 3)). (4)


Question 3 — Pandas Data Wrangling (14 marks)

A CSV file sales.csv has columns: date (YYYY-MM-DD), region, product, units, price.

(a) Write code to load the file, parse date as datetime, and create a new column revenue = units * price. (4)

(b) Produce a table of total revenue per region per month, with regions as rows and months as columns. Name the pandas operation(s) you use. (5)

(c) A colleague reports that df.groupby("region")["revenue"].mean() gives a different answer than expected because some units values are stored as strings like "12". Explain why this silently corrupts the result and give one line to detect the problem and one line to fix it. (5)


Question 4 — Functions, Classes, Debugging (12 marks)

Consider this code intended to accumulate results:

def add_item(item, bucket=[]):
    bucket.append(item)
    return bucket
 
a = add_item(1)
b = add_item(2)

(a) State the values of a and b after execution and explain the underlying cause. (4)

(b) Rewrite add_item so each call with no bucket starts fresh. (3)

(c) Design a small class RunningStats that supports .update(x) and exposes properties .mean and .count, computing the mean incrementally (without storing all values). Show the formula used for the incremental mean update. (5)


Question 5 — Environments, Git & Reproducibility (10 marks)

(a) A teammate cannot reproduce your results. Describe the exact sequence of commands you would give them to create an isolated environment and install the pinned dependencies from your requirements.txt. (4)

(b) You accidentally committed a 500 MB data file and a secrets file. The commit is already pushed. Describe the correct remediation and one preventative measure. (3)

(c) Explain the difference between git fetch and git pull, and when you would prefer fetch. (3)

Answer keyMark scheme & solutions

Question 1 (12)

(a) (6 marks)

D = ((X[:, None, :] - C[None, :, :])**2).sum(axis=2)
  • X[:, None, :] has shape (M, 1, d)(1)
  • C[None, :, :] has shape (1, N, d)(1)
  • Broadcasting aligns to (M, N, d); element-wise subtract then square — (2)
  • .sum(axis=2) reduces the d axis giving (M, N)(2)

Alternative accepted (identity xc2=x22xc+c2\|x-c\|^2 = \|x\|^2 - 2x\cdot c + \|c\|^2):

D = (X**2).sum(1)[:,None] - 2*X@C.T + (C**2).sum(1)[None,:]

Full marks if correct and vectorized.

(b) (3 marks)

  • X[:, None, :](1000, 1, 3)(0.5)
  • C[None, :, :](8, 3) broadcast to (1, 8, 3)(0.5)
  • difference/square → (1000, 8, 3)(1)
  • final D(1000, 8)(1)

(c) (3 marks)

nearest = D.argmin(axis=1)   # shape (M,)

argmin over axis=1 (the centroid axis) — (3) (1 for correct axis).


Question 2 (12)

(a) (4 marks)

freq = {w: words.count(w) for w in set(words)}
# {'ml':3, 'ai':2, 'data':1}
  • dict comprehension over set(words) to avoid repeats — (2)
  • .count gives frequency — (1)
  • correct result — (1)

(b) (4 marks)

  • a is a list: all 10^7 squares are computed and stored in memory at once (~hundreds of MB) — (1.5)
  • b is a generator: lazy, computes each value on demand, O(1) memory — (1.5)
  • b preferable when streaming/iterating once over huge data that need not be materialised (e.g. summing) — (1)

(c) (4 marks)

def batched(iterable, n):
    batch = []
    for x in iterable:
        batch.append(x)
        if len(batch) == n:
            yield batch
            batch = []
    if batch:
        yield batch
  • correct yielding of full batches — (2)
  • final partial batch handled — (1)
  • list(batched(range(7),3))[[0,1,2],[3,4,5],[6]](1)

Question 3 (14)

(a) (4 marks)

df = pd.read_csv("sales.csv", parse_dates=["date"])
df["revenue"] = df["units"] * df["price"]
  • parse_dates(2), new column — (2)

(b) (5 marks)

df["month"] = df["date"].dt.to_period("M")
table = df.pivot_table(index="region", columns="month",
                       values="revenue", aggfunc="sum")
  • extract month via .dt(1)
  • pivot_table / groupby(...).sum().unstack()(3)
  • correct orientation (region rows, month columns) — (1)

(c) (5 marks)

  • If units is stored as strings, units * price may do string repetition or the column dtype is object; mean() may skip/misbehave or arithmetic silently produces wrong numeric values (concatenation/coercion). Because pandas does not raise, the error is silent — (2)
  • Detect: df["units"].dtype or df["units"].apply(type).value_counts()(1.5)
  • Fix: df["units"] = pd.to_numeric(df["units"], errors="coerce")(1.5)

Question 4 (12)

(a) (4 marks)

  • a == [1, 2] and b == [1, 2] — they are the same list object(2)
  • Cause: the default argument bucket=[] is evaluated once at function definition, so the same list persists across calls (mutable default argument pitfall) — (2)

(b) (3 marks)

def add_item(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket
  • sentinel None default — (2), fresh list per call — (1)

(c) (5 marks)

class RunningStats:
    def __init__(self):
        self._n = 0
        self._mean = 0.0
    def update(self, x):
        self._n += 1
        self._mean += (x - self._mean) / self._n
    @property
    def mean(self):
        return self._mean
    @property
    def count(self):
        return self._n

Incremental (Welford) mean update: μn=μn1+xnμn1n\mu_n = \mu_{n-1} + \frac{x_n - \mu_{n-1}}{n}

  • correct incremental formula — (2)
  • .update logic — (1)
  • mean/count as properties — (2)

Question 5 (10)

(a) (4 marks)

python -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -r requirements.txt

(conda equivalent accepted). Each correct step — (1) each, activation noted — (1).

(b) (3 marks)

  • Remove file from history (e.g. git filter-repo / BFG), force-push, and rotate the leaked secret (assume compromised) — (2)
  • Prevention: add patterns to .gitignore (and use git-secrets / pre-commit hook) — (1)

(c) (3 marks)

  • git fetch downloads remote commits/refs but does not modify your working branch — (1)
  • git pull = fetch + merge (or rebase) into current branch — (1)
  • Prefer fetch to inspect/review changes before integrating, avoiding surprise merges — (1)

[
  {"claim":"Squared distance via broadcasting equals identity formula",
   "code":"import numpy as np\nX=np.random.rand(5,3); C=np.random.rand(4,3)\nD1=((X[:,None,:]-C[None,:,:])**2).sum(2)\nD2=(X**2).sum(1)[:,None]-2*X@C.T+(C**2).sum(1)[None,:]\nresult=bool(np.allclose(D1,D2)) and D1.shape==(5,4)"},
  {"claim":"argmin axis=1 gives nearest centroid shape (M,)",
   "code":"import numpy as np\nX=np.random.rand(10,3); C=np.random.rand(4,3)\nD=((X[:,None,:]-C[None,:,:])**2).sum(2)\nnn=D.argmin(1)\nresult=nn.shape==(10,) and bool((D[np.arange(10),nn]==D.min(1)).all())"},
  {"claim":"word frequency dict comprehension correct",
   "code":"words=['ml','ai','data','ml','ai','ml']\nfreq={w:words.count(w) for w in set(words)}\nresult=freq=={'ml':3,'ai':2,'data':1}"},
  {"claim":"batched(range(7),3) yields expected batches",
   "code":"def batched(it,n):\n b=[]\n for x in it:\n  b.append(x)\n  if len(b)==n: yield b; b=[]\n if b: yield b\nresult=list(batched(range(7),3))==[[0,1,2],[3,4,5],[6]]"},
  {"claim":"Welford incremental mean equals arithmetic mean",
   "code":"import numpy as np\nxs=[3.0,5.0,7.0,10.0,2.0]\nn=0; m=0.0\nfor x in xs:\n n+=1; m+=(x-m)/n\nresult=abs(m-np.mean(xs))<1e-12 and n==5"}
]