Lesson 04: Scientific Python Stack

These libraries underpin almost all computational physics in Python. You do not need to memorise the API. You need to understand what each one is for and how to look things up efficiently.

Install everything at once:

pip install numpy scipy pandas matplotlib h5py

NumPy

NumPy provides the ndarray: a fast, multi-dimensional array that supports vectorised operations. Almost every other scientific library builds on it.

Creating arrays

import numpy as np

a = np.array([1.0, 2.0, 3.0])         # from a list
b = np.zeros(100)                       # 100 zeros
c = np.linspace(0, 1, 50)              # 50 evenly spaced values in [0, 1]
d = np.logspace(2, 6, 100)             # 100 points, 10^2 to 10^6
e = np.arange(0, 10, 0.5)             # 0.0, 0.5, 1.0, ...
M = np.zeros((3, 3))                   # 2-D array, shape (3, 3)

Operations are element-wise

E = np.logspace(2, 6, 1000)   # energies in GeV
phi = 1e-4 * E ** -2.0        # power-law spectrum — no loop needed

This is vectorisation: operations apply to every element simultaneously. It is typically 100–1000× faster than an equivalent Python for loop, because the loop runs in compiled C code inside NumPy rather than in the Python interpreter. Vectorisation is your single most important performance tool. See Lesson 06 for a deeper discussion.

Indexing and slicing

a = np.arange(10)       # [0, 1, 2, ..., 9]

a[0]                    # 0      — first element
a[-1]                   # 9      — last element
a[2:5]                  # [2, 3, 4]
a[a > 5]                # [6, 7, 8, 9]   — boolean mask

Boolean masks are particularly useful in physics: events[events["energy"] > 1e5] selects only high-energy events without a loop.

Useful functions

np.sum(a)                        # sum all elements
np.mean(a), np.std(a)            # statistics
np.max(a), np.min(a)             # extremes
np.sort(a)                       # sorted copy (does not modify a)
np.where(a > 3, a, 0)            # conditional: keep if > 3, else 0
np.dot(v1, v2)                   # dot product
np.cross(v1, v2)                 # cross product
np.linalg.norm(v)                # vector norm
np.histogram(data, bins=50)      # histogram

Saving and loading

np.save("spectrum.npy", phi)
phi_loaded = np.load("spectrum.npy")

# Plain text (slower, human-readable)
np.savetxt("spectrum.txt", np.column_stack([E, phi]))
data = np.loadtxt("spectrum.txt")

SciPy

SciPy extends NumPy with algorithms for integration, optimisation, interpolation, linear algebra, and statistics. Each lives in a submodule.

Integration

from scipy import integrate

def integrand(E, gamma):
    return E ** (-gamma)

result, error = integrate.quad(integrand, 1e2, 1e6, args=(2.0,))
print(f"Integral = {result:.4e} ± {error:.2e}")

For 2-D integrals, use integrate.dblquad. For integrals over sampled data, use integrate.trapezoid.

Interpolation

from scipy.interpolate import interp1d

f = interp1d(E_data, phi_data, kind="cubic", bounds_error=False, fill_value=0.0)
phi_fine = f(E_fine)

Optimisation and fitting

from scipy.optimize import curve_fit, brentq

def power_law(E, norm, gamma):
    return norm * E ** (-gamma)

popt, pcov = curve_fit(power_law, E_data, phi_data, p0=[1e-4, 2.0])
norm_fit, gamma_fit = popt
perr = np.sqrt(np.diag(pcov))   # 1-sigma uncertainties

# Root finding
E_cross = brentq(lambda E: power_law(E, *popt) - threshold, 1e3, 1e8)

Statistics

from scipy import stats

ks_stat, p_value = stats.kstest(samples, "norm")  # Kolmogorov-Smirnov
chi2, p = stats.chisquare(observed, expected)

Pandas

Pandas provides the DataFrame: a labelled 2-D table, analogous to a spreadsheet or SQL table. It is most useful when you have tabular data: event catalogues, parameter scans, observational tables with mixed column types.

import pandas as pd

df = pd.read_csv("events.csv")      # load
df.head()                           # inspect first five rows
df.describe()                       # summary statistics

# Selecting columns
energies = df["energy_GeV"]

# Filtering rows
high_energy = df[df["energy_GeV"] > 1e5]

# Grouping
by_type = df.groupby("particle_type")["energy_GeV"].mean()

# Saving
df.to_csv("output.csv", index=False)
df.to_hdf("output.h5", key="events")

Pandas is less useful for pure numerical computation. Prefer NumPy arrays there. It shines for loading mixed-type data, quick aggregation, and preparing tables for a paper.


HDF5 and h5py

Physics simulations produce large binary files. HDF5 is the standard format: structured, compressed, and fast.

import h5py

# Writing
with h5py.File("simulation.h5", "w") as f:
    f.create_dataset("energies", data=E_array, compression="gzip")
    f.create_dataset("weights", data=w_array)
    f.attrs["description"] = "Neutrino MC run 001"
    f.attrs["n_events"] = len(E_array)

# Reading
with h5py.File("simulation.h5", "r") as f:
    E = f["energies"][:]     # [:] loads into memory
    w = f["weights"][:]
    print(f.attrs["description"])

Prometheus writes its output in HDF5. The Pandas read_hdf / to_hdf methods use the same format.


Matplotlib

Matplotlib produces publication-quality plots. Full coverage is in the Plotting track; a minimal example:

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(6, 4))
ax.loglog(E, phi, color="steelblue", label=r"$\gamma = 2.37$")
ax.set_xlabel("Energy [GeV]")
ax.set_ylabel(r"$\Phi$ [GeV$^{-1}$ cm$^{-2}$ s$^{-1}$ sr$^{-1}$]")
ax.legend()
fig.tight_layout()
fig.savefig("spectrum.pdf")

Always save to PDF for vector graphics; use PNG only for quick inspection.


Domain-specific libraries worth knowing

Library Purpose
astropy Coordinate systems, FITS files, cosmology, units
healpy HEALPix sky maps (CMB, neutrino sky maps)
iminuit Maximum-likelihood fitting (MINUIT wrapper)
uproot Read/write ROOT files without a ROOT installation

A worked physics example

Compute the muon neutrino flux from a power-law source, then find the energy at which the integrated flux above that energy drops below a given threshold.

import numpy as np
from scipy import integrate, optimize

GAMMA = 2.37                  # spectral index (IceCube best-fit)
NORM = 1.66e-18               # normalisation [GeV^-1 cm^-2 s^-1 sr^-1] at 100 TeV
E_REF = 1e5                   # reference energy [GeV]

def flux(E: float) -> float:
    return NORM * (E / E_REF) ** (-GAMMA)

def flux_above(E_min: float) -> float:
    """Integrated flux above E_min [cm^-2 s^-1 sr^-1]."""
    result, _ = integrate.quad(flux, E_min, np.inf)
    return result

threshold = 1e-22             # cm^-2 s^-1 sr^-1

E_cutoff = optimize.brentq(
    lambda E: flux_above(E) - threshold,
    1e3, 1e8
)
print(f"Integrated flux drops below threshold at {E_cutoff:.2e} GeV")

This pattern, define a flux function, integrate it, invert the integral, appears constantly in neutrino physics, cosmic-ray analysis, and gravitational-wave parameter estimation.


Further reading

The Scientific Python Lectures (formerly the SciPy Lecture Notes) are the canonical free reference for the scientific Python stack. They cover NumPy, SciPy, and matplotlib in considerably more depth than this lesson, with worked examples throughout. If you want to go beyond the essentials covered here, start there.

Lesson 05 covers code style: why consistent formatting matters, and how to enforce it automatically with ruff.