Exercises — SciPy — overview of submodules
Before we begin, a reminder of two words we will lean on:
- An array is just a fast grid of numbers (see NumPy arrays). SciPy never invents its own data type — it works on these grids.
- A submodule is one drawer of the SciPy toolbox, named after the maths it does:
linalg,optimize,integrate,interpolate,stats,sparse,fft, …
Level 1 — Recognition
Goal: given a task, name the right drawer. No coding yet — just point.
L1.1
For each task, name the single best core submodule.
- Solve the system .
- Find the that makes smallest.
- Compute numerically.
- Draw a smooth curve through the scattered data points .
- Get the mean and standard deviation of a normal distribution.
Recall Solution
A verb hides in each task; match verb → drawer.
- solve a linear system → ==
scipy.linalg== (see Linear algebra — solving Ax=b). - minimize → ==
scipy.optimize== (see Optimization — gradient descent). - integrate → ==
scipy.integrate== (see Numerical integration (trapezoid & Simpson)). - interpolate (curve through points) → ==
scipy.interpolate==. - distribution / statistics → ==
scipy.stats== (see Probability distributions).
L1.2
quad returns two numbers. What are they, and why two?
Recall Solution
It returns the tuple (value, abserr) — the estimate of the integral and an absolute
error bound. Two numbers because a numerical answer is worthless without knowing how much to
trust it. Always unpack both: val, err = quad(...).
Level 2 — Application
Goal: write the call and predict the number before running it.
L2.1
Using scipy.linalg.solve, solve
Predict by hand first, then give the code.
Recall Solution
By hand. From the first equation . Substitute into the second: Then . So .
from scipy import linalg
import numpy as np
A = np.array([[2, 1], [1, 3]], dtype=float)
b = np.array([5, 10], dtype=float)
print(linalg.solve(A, b)) # [1. 3.]Why solve and not inv(A) @ b? solve uses LU decomposition — it factors once and
back-substitutes, which is faster and numerically steadier than forming explicitly.
L2.2
Compute with integrate.quad. Predict the exact value.
Recall Solution
By hand.
from scipy import integrate
import numpy as np
val, err = integrate.quad(np.sin, 0, np.pi)
print(val, err) # 2.0 ~2e-14The estimate is and the error bound is around — essentially exact.
L2.3
Minimize with optimize.minimize, starting from x0=0.
Predict the minimizer and the minimum value.
Recall Solution
By hand. The minimum of a squared term is where the inside is zero, so . The added shifts the value up but not the location: . Check with calculus: .
from scipy import optimize
res = optimize.minimize(lambda x: (x-3)**2 + 4, x0=0.0)
print(res.x, res.fun) # [3.0000...] [4.0000...]Level 3 — Analysis
Goal: reason about trade-offs, correctness, and when a routine breaks.
L3.1
You must store a matrix of float64 where all but million entries are
zero. Estimate the memory a dense array needs, and say which submodule you should use instead.
Recall Solution
A float64 takes 8 bytes. A dense array stores every cell:
That will never fit in RAM. Because it is mostly zeros, use `scipy.sparse`,
> which stores only the nonzero entries plus their positions. Roughly nonzeros need
on the order of tens of megabytes — a factor of about smaller.
L3.2
Two students solve the same : one with numpy.linalg.solve, one with scipy.linalg.solve.
They get the same answer on a system. Does that prove the two are interchangeable? Give
one concrete reason to prefer scipy.linalg.
Recall Solution
No — agreeing on one tiny problem proves nothing about the general case. scipy.linalg is
always compiled against BLAS/LAPACK and offers more routines (lu, qr, schur,
expm, …) that numpy.linalg simply lacks. For serious numerical work — decompositions, matrix
functions, large systems — prefer scipy.linalg. See Linear algebra — solving Ax=b.
L3.3
The trapezoid rule approximates by summing straight-topped strips. Look at the figure: on a curve that bends upward (convex), does one trapezoid strip over-estimate or under-estimate the true area? Explain from the picture.

Recall Solution
For a curve that bends upward, the straight chord joining the two endpoints lies above the
curve (red chord above the black curve in the figure). The trapezoid's flat top therefore covers
more area than the region under the curve, so a single strip over-estimates. This is exactly
the wiggliness integrate.quad fixes by refining adaptively — it adds more, thinner strips
(or higher-order pieces) where the curve bends, driving the error down. See
Numerical integration (trapezoid & Simpson).
Level 4 — Synthesis
Goal: chain two or three submodules into one small pipeline.
L4.1
A physical process is described by the density on .
- Confirm it is a valid probability density by integrating it over (
integrate.quad), checking the returned error bound is tiny. - Then find the point where the running area equals (the median), by solving
with
optimize.brentq.
Predict both answers by hand first.
Recall Solution
Step 1 — is it a density? A density must integrate to : Step 2 — the median. The running area from to is Set it to :
from scipy import integrate, optimize
f = lambda x: 3*x**2
area, area_err = integrate.quad(f, 0, 1)
assert area_err < 1e-10 # honour L1.2: read the error bound
print(area, area_err) # 1.0 ~1e-14 -> valid density
def running_area(m):
val, err = integrate.quad(f, 0, m) # unpack BOTH, per L1.2
return val - 0.5 # root here => median
m = optimize.brentq(running_area, 0, 1)
print(m) # 0.7937005...Why brentq? We need a root of on a known bracket
where changes sign (, ). brentq reliably brackets and
squeezes onto that sign change. Note we still unpack val, err inside running_area — the
advice from L1.2 does not lapse just because quad sits in a helper. This is the optimize verb
(root-finding) feeding on the integrate verb — two drawers in one pipeline.
See Probability distributions.
L4.2
Fit a straight line to the exact points using
scipy.optimize.curve_fit, and predict and .
Recall Solution
The points lie exactly on a line: from we read ; the slope between and is , so . Every point satisfies .
from scipy import optimize
import numpy as np
x = np.array([0, 1, 2], dtype=float)
y = np.array([1, 3, 5], dtype=float)
popt, _ = optimize.curve_fit(lambda x, m, c: m*x + c, x, y)
print(popt) # [2. 1.] -> m=2, c=1Which drawer is this? curve_fit lives in ==scipy.optimize==, not scipy.linalg — it
wraps MINPACK's Levenberg–Marquardt least-squares solver, and least-squares fitting is an
optimization verb ("minimize the squared residuals"). scipy.linalg also can do least squares
(via linalg.lstsq, a decomposition), but the moment you phrase the task as "fit these
parameters," the natural drawer is optimize. See Optimization — gradient descent.
Level 5 — Mastery
Goal: reason about correctness, edge cases, and numerical behaviour like an expert.
L5.1
optimize.minimize on starting at x0=0 returns . But minimizers only
guarantee a local minimum. Construct a function where the starting guess changes the answer,
and explain why.
Recall Solution
Take . Its derivative is zero at
. The two minima are at (each giving ); is a local
maximum. A downhill search starting at x0 = -1 slides to ; starting at x0 = +1 slides
to . Same function, different basin, different reported minimizer.
from scipy import optimize
f = lambda x: x**4 - 8*x**2
print(optimize.minimize(f, x0=-1.0).x) # ~[-2.]
print(optimize.minimize(f, x0= 1.0).x) # ~[ 2.]Lesson: local optimizers find the valley you start in. For global minima you must try multiple starts or a global method. See Optimization — gradient descent.
L5.2
The figure shows with both starting guesses marked. State, from the picture, which minimum each guess falls into and why the point traps neither.

Recall Solution
The red dot at sits on the left downslope, so the search rolls left into the valley at . The red dot at sits on the right downslope and rolls right into . The hump at is a local maximum — its slope is zero but the curve bends down on both sides, so any nudge escapes it. A gradient search will never rest there because it always steps in the downhill direction and has downhill on either side.
L5.3
Sample-and-transform mastery. Draw samples from a normal distribution
with scipy.stats.norm.rvs (the sample verb), then take the
Fast Fourier Transform of those samples with scipy.fft.fft (the transform verb). Predict two
sanity checks before running: (a) the sample mean should sit near , and (b) the FFT output must
have exactly complex entries. Explain why these two drawers are different verbs even though
both crunch the same array.
Recall Solution
import numpy as np
from scipy import stats, fft
N = 1024
samples = stats.norm.rvs(loc=0, scale=1, size=N, random_state=0) # SAMPLE verb
print(round(samples.mean(), 1)) # ~0.0 (law of large numbers)
print(round(samples.std(), 1)) # ~1.0
spectrum = fft.fft(samples) # TRANSFORM verb
print(spectrum.shape) # (1024,) -> same length, now complex
print(spectrum.dtype) # complex128(a) With draws from , the sample mean is very close to and the
sample standard deviation close to — that is what "sampling from" a distribution means:
rvs = random variates (see Probability distributions).
(b) fft.fft returns exactly one complex number per input point, so the output has shape
— same length as the input, but now living in the frequency domain instead of the
time/sample domain (see Fourier transform (FFT)).
Why different drawers? scipy.stats.rvs answers "give me random numbers shaped like this
distribution" (the sample verb); scipy.fft.fft answers "which frequencies is this signal
built from?" (the transform verb). Two distinct verbs, two distinct drawers, even though both
operate on the very same NumPy array.
Recall One-line summary of the whole ladder
Match the verb → pick the drawer → predict by hand → run → read the error / check the bracket / question the local optimum. That loop is the entire skill.