MATH40006 Practice Paper B Solutions
English review edition prepared on 4 October 2026 from a preserved source copy. It is a later presentation, not the historical study interface. Source checks and difficulty judgements describe the original material author's own process; they do not indicate Imperial College London endorsement. This material's source date is 2026-08-30; it is later than the recorded activity ending 21 August 2026 and is not evidence of use during that period.
Source SHA-256: 91803c62022233a857dfca80b51857bae3c51d840ab6125a40469faf8c8ca0f6Source date: 2026-08-30
Notes — cell 1
# MATH40006 - Introduction to Computation ## Practice Paper Paper B - Executed solutions **REVIEW CANDIDATE - NOT PUBLISHED** - Practice duration: 90 minutes - Total: 75 marks; answer all three questions - Work in this single Jupyter notebook. - Use code cells for programs, tests, figures and requested output. - Use Markdown answer cells for explanations, invariants, derivations and comments. - No external files, network access, `input()` or package installation are required. - Before submission, restart the kernel and run all cells from top to bottom. The 90-minute/75-mark structure is a review assumption based on the nearest alternative-assessment precedent and must be checked against Tony's own notice.
Code — cell 2
%matplotlib inline
import sys
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import sympy as sp
import pandas as pd
print('Environment ready:', sys.version.split()[0], 'NumPy', np.__version__, 'Matplotlib', matplotlib.__version__)
print('SymPy', sp.__version__, 'pandas', pd.__version__)
Environment ready: 3.12.10 NumPy 2.3.5 Matplotlib 3.11.1 SymPy 1.14.0 pandas 3.0.1
Notes — cell 3
## Question 1 (30 marks): Vectorised absorbing random walks
A walk occupies an integer in `{0,1,...,L}`. From an interior point it moves
right with probability `p` and left otherwise, stopping at `0` or `L`. The
valid contract requires positive integer `n_walks,L`, integer `x0` in `[0,L]`,
`p` in `[0,1]`, and non-negative integer `max_steps`.
**(a) [3]** With `np.random.default_rng(seed)`, demonstrate a Boolean array
encoding right/left moves for several walkers.
**(b) [10]** Write
`absorbing_walks(n_walks,L,x0,p=0.5,max_steps=10000,seed=0)`. Vectorise over
walkers and loop only over time. Return Boolean `hit_right` and integer
absorption times. Raise a clear error if active walks remain at `max_steps`.
**(c) [5]** Test shapes, fixed-seed reproducibility, non-negative times,
immediate absorption at `x0=0,L`, and invalid inputs.
**(d) [5]** For 50000 fair walks with `L=20,x0=7,seed=20260829`, estimate the
right-hit probability and mean absorption time. Compare with `x0/L` and
`x0*(L-x0)`.
**(e) [4]** Construct the stated normal-approximation 95% interval. In
Markdown, check whether `7/20` lies inside and interpret cautiously. Random
agreement must not be a pass/fail assertion.
**(f) [3]** Plot an absorption-time histogram. In Markdown, comment on its
shape and explain the `max_steps` safety contract.Notes — cell 4
### Q1 code answers for parts (a)-(f) - solution
Code — cell 5
import numpy as np
import matplotlib.pyplot as plt
demo_rng = np.random.default_rng(20260829)
demo_right_moves = demo_rng.random(8) < 0.5
assert demo_right_moves.dtype == bool and demo_right_moves.shape == (8,)
def absorbing_walks(n_walks, L, x0, p=0.5, max_steps=10000, seed=0):
"""Simulate vectorised walks absorbed at 0 or L."""
integer_inputs = (n_walks, L, x0, max_steps)
if any(not isinstance(value, (int, np.integer))
or isinstance(value, (bool, np.bool_)) for value in integer_inputs):
raise TypeError("n_walks, L, x0 and max_steps must be integers")
if n_walks < 1 or L < 1 or not (0 <= x0 <= L):
raise ValueError("invalid size or starting point")
if not (0 <= p <= 1) or max_steps < 0:
raise ValueError("invalid probability or max_steps")
rng = np.random.default_rng(seed)
positions = np.full(n_walks, x0, dtype=int)
steps = np.zeros(n_walks, dtype=int)
active = (positions > 0) & (positions < L)
elapsed = 0
while active.any() and elapsed < max_steps:
move_right = rng.random(n_walks) < p
positions[active] += np.where(move_right[active], 1, -1)
steps[active] += 1
active = (positions > 0) & (positions < L)
elapsed += 1
if active.any():
raise RuntimeError("max_steps exceeded")
hit_right = positions == L
return hit_right, steps
right1, steps1 = absorbing_walks(500, 10, 3, seed=20260829)
right2, steps2 = absorbing_walks(500, 10, 3, seed=20260829)
assert np.array_equal(right1, right2)
assert np.array_equal(steps1, steps2)
assert right1.shape == steps1.shape == (500,)
assert np.all(steps1 >= 1)
assert absorbing_walks(4, 5, 0, seed=1)[1].tolist() == [0, 0, 0, 0]
right_at_L, steps_at_L = absorbing_walks(4, 5, 5, seed=1)
assert right_at_L.all() and steps_at_L.tolist() == [0, 0, 0, 0]
try:
absorbing_walks(10, 5, 6)
except ValueError:
pass
else:
raise AssertionError("invalid starting point was not rejected")
try:
absorbing_walks(10.5, 5, 2)
except TypeError:
pass
else:
raise AssertionError("non-integer n_walks was not rejected")
hit_right, durations = absorbing_walks(
50000, L=20, x0=7, p=0.5, max_steps=10000, seed=20260829
)
p_hat = hit_right.mean()
mean_duration = durations.mean()
se = np.sqrt(p_hat * (1 - p_hat) / len(hit_right))
interval_95 = (p_hat - 1.96 * se, p_hat + 1.96 * se)
contains_theory = interval_95[0] <= 7/20 <= interval_95[1]
fig, ax = plt.subplots(figsize=(7.0, 4.2))
ax.hist(durations, bins=45, color="#2563A7", edgecolor="white")
ax.set(xlabel="absorption time", ylabel="number of walks",
title="Fair absorbing walk: L=20, x0=7")
fig.tight_layout()
plt.show()
<Figure size 700x420 with 1 Axes>
Code — cell 6
print(f"B1 p_hat={p_hat:.6f}, mean={mean_duration:.5f}, 95% CI={interval_95}, contains 7/20={contains_theory}")
B1 p_hat=0.350960, mean=91.16992, 95% CI=(np.float64(0.34677654009130854), np.float64(0.35514345990869145)), contains 7/20=True
Notes — cell 7
### Q1 written answers for parts (d)-(f) - solution For the fair walk, the theoretical benchmarks are `P(hit L)=x0/L=7/20` and `E[T]=x0(L-x0)=91`. The displayed interval is a random, approximate diagnostic rather than proof, so agreement is recorded as a Boolean result and not asserted. The histogram is right-skewed with a long tail. `max_steps` guarantees controlled failure if inputs or a run behave unexpectedly.
Notes — cell 8
## Question 2 (35 marks): Horner evaluation, derivatives and Newton iteration `[c0,...,cn]` represents `p(x)=c0*x**n+...+cn` in descending powers. **(a) [6]** Write `horner(coefficients,x)` using `value=value*x+c`. Accept a real scalar or real NumPy array, reject an empty list and do not mutate input. **(b) [5]** Test a constant polynomial, vector `x`, repeated/zero coefficients and agreement with `np.polyval`. **(c) [4]** In Markdown, give the exact multiplication/addition counts and Theta time; contrast with rebuilding every power by repeated multiplication. **(d) [8]** Derive in Markdown and implement `horner_with_derivative(coefficients,x)`, using the old polynomial accumulator when updating the derivative. **(e) [6]** For coefficients `[1,1,-7,-1,6]` and 601 points on `[-3,3]`, verify `p` with `np.polyval`, verify `p'` with SymPy `diff` and `lambdify`, and plot both. **(f) [6]** Write guarded Newton iteration using extended Horner. Stop on a step below `tol`; reject a near-zero derivative and exhausted `max_iter`. Starting at `0.8`, verify the root and residual.
Notes — cell 9
### Q2 code answers for parts (a), (b), (d), (e) and (f) - solution
Code — cell 10
import numpy as np
import matplotlib.pyplot as plt
import sympy as sp
def horner(coefficients, x):
"""Evaluate descending-power coefficients by Horner's method."""
coeffs = list(coefficients)
if not coeffs:
raise ValueError("at least one coefficient is required")
scalar_input = np.ndim(x) == 0
value = np.full(np.shape(x), coeffs[0], dtype=float)
for coefficient in coeffs[1:]:
value = value * x + coefficient
return value.item() if scalar_input else value
def horner_with_derivative(coefficients, x):
"""Evaluate p and p' simultaneously by extended Horner."""
coeffs = list(coefficients)
if not coeffs:
raise ValueError("at least one coefficient is required")
scalar_input = np.ndim(x) == 0
value = np.full(np.shape(x), coeffs[0], dtype=float)
derivative = np.zeros(np.shape(x), dtype=float)
for coefficient in coeffs[1:]:
derivative = derivative * x + value
value = value * x + coefficient
if scalar_input:
return value.item(), derivative.item()
return value, derivative
def newton_horner(coefficients, x0, tol=1e-12, max_iter=50):
"""Newton iteration using extended Horner for p and p'."""
x = float(x0)
for iteration in range(max_iter):
value, derivative = horner_with_derivative(coefficients, x)
if abs(derivative) < 1e-14:
raise RuntimeError("derivative too small")
new_x = x - value / derivative
if abs(new_x - x) < tol:
return new_x, iteration + 1
x = new_x
raise RuntimeError("maximum iterations exceeded")
coeffs = [1, 1, -7, -1, 6]
saved_coeffs = coeffs.copy()
x_grid = np.linspace(-3, 3, 601)
values, derivatives = horner_with_derivative(coeffs, x_grid)
assert coeffs == saved_coeffs
assert np.allclose(values, np.polyval(coeffs, x_grid))
assert horner([7], np.array([-1.0, 2.0])).tolist() == [7.0, 7.0]
zero_case = [2, 0, 0, -3]
assert np.allclose(horner(zero_case, x_grid), np.polyval(zero_case, x_grid))
s = sp.symbols("s", real=True)
symbolic = sum(c * s**(len(coeffs)-1-i) for i, c in enumerate(coeffs))
symbolic_derivative = sp.diff(symbolic, s)
derivative_function = sp.lambdify(s, symbolic_derivative, "numpy")
assert np.allclose(derivatives, derivative_function(x_grid))
root, iterations = newton_horner(coeffs, 0.8)
assert abs(root - 1.0) < 1e-12
assert abs(horner(coeffs, root)) < 1e-12
fig, ax = plt.subplots(figsize=(7.0, 4.2))
ax.plot(x_grid, values, label="p(x)")
ax.plot(x_grid, derivatives, label="p'(x)")
ax.axhline(0, color="black", linewidth=0.7)
ax.set(xlabel="x", ylabel="value", title="Horner evaluation and derivative")
ax.legend()
fig.tight_layout()
plt.show()
<Figure size 700x420 with 1 Axes>
Code — cell 11
print(f"B2 root={root:.12f}, iterations={iterations}, residual={abs(horner(coeffs, root)):.3e}")
B2 root=1.000000000000, iterations=4, residual=0.000e+00
Notes — cell 12
### Q2 written answers for parts (c) and (d) - solution Degree-n Horner evaluation makes exactly n multiplications and n additions, hence Theta(n); separately rebuilding each power by repeated multiplication costs Theta(n**2). Differentiating the nested update gives `derivative=derivative*x+value`, which must use the old `value` before `value=value*x+c`. Newton checks a nearly zero derivative and maximum iterations; both final step size and residual are reported.
Notes — cell 13
## Question 3 (10 marks): A pandas report for a supplied simulation log Run the non-assessed **GIVEN SETUP** cell below unchanged. It creates a reproducible synthetic run log, so this question has no external-file or Question 1 dependency. **(a) [3]** Build a DataFrame with one row per record and columns `side` (`'left'/'right'`) and `steps`. **(b) [4]** With `groupby` and `agg`, report count, mean, median and maximum duration for each side. Verify counts total `report_n`. **(c) [3]** Find the 99th percentile of `steps`, use `query` to select records at or above it, and count by side. In Markdown, explain why this conditional subset cannot estimate the unconditional right-side probability.
Code — cell 14
import numpy as np import pandas as pd # GIVEN SETUP - run this cell unchanged before Question 3. report_rng = np.random.default_rng(4000603) report_n = 50000 report_hit_right = report_rng.random(report_n) < 0.35 report_durations = report_rng.geometric(1 / 91.0, size=report_n)
Notes — cell 15
### Q3 code answers for parts (a)-(c) - solution
Code — cell 16
walks = pd.DataFrame({
"side": np.where(report_hit_right, "right", "left"),
"steps": report_durations,
})
summary = walks.groupby("side")["steps"].agg(
count="size", mean="mean", median="median", maximum="max"
)
cutoff = walks["steps"].quantile(0.99)
long_walks = walks.query("steps >= @cutoff")
long_counts = long_walks.groupby("side").size()
assert len(walks) == report_n
assert summary["count"].sum() == report_n
assert set(summary.index) == {"left", "right"}
assert len(long_walks) >= report_n // 100
assert long_counts.sum() == len(long_walks)
Code — cell 17
display(summary); print("99% cutoff:", cutoff); display(long_counts.rename("count"))
count mean median maximum side left 32523 91.053685 62.0 954 right 17477 90.714196 63.0 920
99% cutoff: 417.0
side left 334 right 169 Name: count, dtype: int64
Notes — cell 18
### Q3 written answer for part (c) - solution The 99th-percentile subset was deliberately selected using duration, so its right/left mix is conditional on being a long record. It cannot replace the unconditional right-side proportion computed from all records. Discrete ties may also leave slightly more than one percent of rows.
Code — cell 19
print('FINAL CHECK: all three questions executed without an exception.')
FINAL CHECK: all three questions executed without an exception.