Getting Started
What is NumPy?
NumPy's power comes from the ndarray: a homogeneous, contiguous-memory n-D array. Because all elements share a type and live in one block, operations run in compiled C/Fortran code (vectorization), making them 10-100x faster than equivalent Python loops. Almost the entire scientific Python stack (pandas, scikit-learn, PyTorch, TensorFlow) is built on top of NumPy arrays.
# NumPy: the fundamental package for numerical computing in Python.
# Core data structure: the n-dimensional array (ndarray).
# Key advantages over pure Python lists:
# - Homogeneous, contiguous memory -> fast vectorized ops
# - Broadcasting: operate on arrays of different shapes
# - Rich set of math / linear algebra / FFT / random routines
# - C/Fortran backend -> orders of magnitude faster than Python loops
import numpy as np
print(np.__version__) # e.g. 1.26.0Install & Import
np is the near-universal alias for NumPy — every tutorial and library expects it, so stick with it. If you need a specific CPU-optimized build, conda often ships MKL-linked NumPy which is faster for linear algebra than the pip default.
# Install NumPy
pip install numpy
# Or with conda
conda install numpy
# The universal convention is to alias as np
import numpy as np
a = np.array([1, 2, 3])
print(a) # [1 2 3]
print(type(a)) # <class 'numpy.ndarray'>ndarray Attributes
Knowing these attributes is the foundation of debugging NumPy code. shape and dtype are the most common: shape tells you the structure, dtype tells you the memory footprint. itemsize * size = nbytes lets you estimate memory usage before allocating huge arrays.
import numpy as np
a = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.int64)
a.ndim # 2 -> number of axes (dimensions)
a.shape # (2, 3) -> tuple of axis sizes
a.size # 6 -> total elements = prod(shape)
a.dtype # int64 -> element type
a.itemsize # 8 -> bytes per element
a.nbytes # 48 -> total bytes = size * itemsize
a.T # shape (3, 2) transposed view
a.flat # flat iterator over all elementsData Types (dtype)
Choosing the right dtype is critical for memory and correctness. float32 halves memory vs float64 with little accuracy loss for most ML work. int8/int16 can overflow silently — np.array([200], dtype=np.int8) becomes -56. Always pick the smallest dtype that safely holds your range, especially for large arrays and embedded/edge deployments.
import numpy as np
# Specifying dtype at creation
a = np.array([1, 2, 3], dtype=np.float32)
b = np.array([1, 2, 3], dtype="float32") # string form works too
# Integer types: int8 int16 int32 int64 (and unsigned uint*)
# Float types: float16 float32 float64 (default)
# Complex: complex64 complex128
# Others: bool, string_, unicode_, object
# Cast an existing array
c = a.astype(np.int32)
# Default int/float match the platform
print(np.dtype(int).itemsize) # 4 or 8
print(np.dtype(float).itemsize) # 8
# Beware overflow:
small = np.array([200], dtype=np.int8) # wraps around!Array vs Python List
The list-comprehension version runs in the Python interpreter (one bytecode per element); the NumPy version dispatches to a single C loop over contiguous memory. For numeric workloads NumPy is typically 20-100x faster and uses far less memory (no per-element Python object). Use lists only when you need heterogeneous types or non-numeric data.
import numpy as np
import time
# Python list: element-wise multiply needs a loop or comprehension
py_list = list(range(1_000_000))
start = time.perf_counter()
py_sq = [x * 2 for x in py_list]
print("list:", time.perf_counter() - start)
# NumPy: vectorized, runs in C
arr = np.arange(1_000_000)
start = time.perf_counter()
np_sq = arr * 2
print("numpy:", time.perf_counter() - start)
# NumPy is typically 20-100x faster for numeric workloads.
# Lists are heterogeneous & flexible; arrays are homogeneous & fast.Array Creation
From Python Structures
np.array() converts lists/tuples (or any nested iterable) into an ndarray. Nesting depth determines the number of dimensions. Specify dtype at creation to avoid an extra copy later — np.array([1,2,3]) defaults to int64 on most platforms, so if you want floats pass dtype=float.
import numpy as np
# From a list (1-D)
a = np.array([1, 2, 3])
# From a list of lists (2-D)
b = np.array([[1, 2, 3],
[4, 5, 6]])
# From nested lists (3-D)
c = np.array([[[1, 2], [3, 4]],
[[5, 6], [7, 8]]])
# Specify dtype up front
d = np.array([1, 2, 3], dtype=np.float32)
# From any iterable
e = np.array(range(5)) # [0 1 2 3 4]
f = np.array((1, 2, 3)) # from a tupleBuilt-in Ranges & Grids
Prefer linspace over arange for floats — arange's step can produce off-by-one endpoints due to floating-point rounding. meshgrid is the standard way to build coordinate grids for 3D plotting, image processing, and finite-difference computations. Use np.mgrid / np.ogrid for open (memory-saving) grids.
import numpy as np
# arange: like range but returns array (watch float edge!)
np.arange(0, 10, 2) # [0 2 4 6 8]
np.arange(0, 1, 0.1) # may or may not include 1.0 (float!)
# linspace: exactly num points, endpoints included by default
np.linspace(0, 1, 5) # [0. 0.25 0.5 0.75 1. ]
# logspace: 10**start .. 10**stop
np.logspace(0, 3, 4) # [ 1. 10. 100. 1000.]
# Meshgrids for evaluating functions on a grid
x = np.linspace(-2, 2, 5)
y = np.linspace(-2, 2, 5)
X, Y = np.meshgrid(x, y)
Z = X**2 + Y**2 # 5x5 grid of valuesZeros, Ones & Constants
zeros/ones/full pre-fill the buffer; empty skips the fill so it's fastest when you'll overwrite every element anyway (e.g. as an output buffer). eye(k=n) builds the identity; eye(k=1) shifts the diagonal up. Always pass dtype to zeros/ones — they default to float64, which wastes memory if you wanted integers.
import numpy as np
np.zeros((2, 3)) # 2x3 of float64 zeros
np.zeros((2, 3), dtype=int) # 2x3 of int zeros
np.ones((3, 3)) # 3x3 of ones
np.full((2, 2), 7) # 2x2 filled with 7
np.full((2, 2), np.pi) # fill with any constant
# Identity & eye
np.eye(3) # 3x3 identity matrix
np.eye(3, k=1) # identity shifted up by one diagonal
np.diag([1, 2, 3]) # diagonal matrix from a vector
# Empty: uninitialized memory (fastest, but garbage values)
np.empty((2, 2)) # allocate without fillingRandom Arrays
Use np.random.default_rng(seed) — the modern Generator is faster, statistically better, and reproducible. The legacy np.random.rand/randn/seed API is global-stateful and discouraged in new code. Always pass an explicit seed for reproducibility in experiments and tests; without it, results differ across runs.
import numpy as np
rng = np.random.default_rng(seed=42) # modern Generator (NumPy >= 1.17)
rng.random((2, 3)) # uniform in [0, 1), shape (2, 3)
rng.standard_normal((2, 3)) # standard normal (mean 0, std 1)
rng.normal(100, 15, size=5) # normal(mu=100, sigma=15)
rng.uniform(0, 10, size=5) # uniform in [low, high)
rng.integers(0, 10, size=5) # random ints in [low, high)
# Reproducibility: always pass a seed
rng2 = np.random.default_rng(42)
print(rng2.random(3)) # same every run
# Legacy API (still common but prefer default_rng)
# np.random.rand(2, 3) # uniform [0,1)
# np.random.randn(2, 3) # standard normalCopying Existing Arrays
Slicing and .view() create views that share memory — editing one changes the other. Use .copy() when you need an independent array. Reshape, ravel, transpose also return views when possible, so always .copy() before mutating if you need the original. Forgetting this is one of the most common NumPy bugs.
import numpy as np
a = np.array([1, 2, 3])
# View (shares memory!) - shape change only
v = a.view()
v[0] = 99
print(a) # [99 2 3] <- a is mutated!
# Deep copy (independent)
b = a.copy()
b[0] = 0
print(a) # [99 2 3] <- a unchanged
# np.array defaults to copying
c = np.array(a)
c is a # False
# Repeat / tile to expand arrays
np.repeat([1, 2, 3], 2) # [1 1 2 2 3 3]
np.tile([1, 2, 3], 2) # [1 2 3 1 2 3]Indexing & Slicing
Basic Slicing
Slicing follows Python's [start:stop:step] convention with stop excluded. Crucially, NumPy slices are views — they share memory with the source, so mutating a slice mutates the original. This is intentional (it saves memory) but a frequent source of bugs. Use .copy() when you need an independent array.
import numpy as np
a = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
a[0] # 0 (scalar)
a[-1] # 9 (last element)
a[2:5] # [2 3 4] start:stop (stop excluded)
a[:5] # [0 1 2 3 4]
a[5:] # [5 6 7 8 9]
a[::2] # [0 2 4 6 8] step=2
a[::-1] # [9 8 7 6 5 4 3 2 1 0] reversed
a[1:7:2] # [1 3 5] start:stop:step
# Slices return VIEWS (not copies) -> editing changes the original
b = a[2:5]
b[0] = 99
print(a[2]) # 99 <- a was modified!Multi-dimensional Indexing
Use a[i, j] rather than a[i][j] — the comma form is one operation and faster. a[:, j] picks a full column. Use ... (Ellipsis) when working with 3+ dimensional arrays to mean 'all remaining axes', avoiding the need to write many colons. Each slice returns a view.
import numpy as np
a = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
a[1, 2] # 6 (row 1, col 2) - equivalent to a[1][2] but faster
a[0] # [1 2 3] (row 0)
a[0, :] # [1 2 3] (row 0, all cols)
a[:, 1] # [2 5 8] (all rows, col 1)
a[1:3, 0:2] # [[4 5]
# [7 8]] rows 1-2, cols 0-1
a[-1] # [7 8 9] last row
a[:, -1] # [3 6 9] last column
# ... (ellipsis) selects any remaining full axes
b = np.zeros((2, 3, 4))
b[..., 0] # shape (2, 3) - last axis indexed, others kept
b[1, ...] # shape (3, 4) - first axis indexedBoolean / Mask Indexing
Boolean indexing always returns a COPY, never a view — safe to mutate freely. Combine masks with & | ~ (NOT with and/or — those operate on the array's truthiness and raise). np.where returns indices; np.where(cond, x, y) is a vectorized ternary. This is the workhorse for filtering and conditional logic.
import numpy as np
a = np.array([1, 2, 3, 4, 5, 6])
# Build a boolean mask
mask = a > 3
# [False False False True True True]
a[mask] # [4 5 6] (select elements where mask is True)
a[a > 3] # same, in one expression
a[(a > 2) & (a < 6)] # [3 4 5] combine with & (and), | (or), ~ (not)
# Modify via mask
a[a > 3] = 0 # set all matching elements
a[a == 0] = -1 # conditional assignment
# Count matching elements
(a > 3).sum() # 3 (True == 1)
np.count_nonzero(a > 3)
# np.where: positions where condition is True
np.where(a > 3) # (array([3, 4, 5]),) indicesFancy / Integer Array Indexing
Fancy (integer-array) indexing also returns a copy, never a view. When indexing a 2-D array with two index arrays b[rows, cols], the arrays are paired element-wise (giving points), NOT a cross product. For a cross product use np.ix_(rows, cols). This distinction is a common source of confusion.
import numpy as np
a = np.array([10, 20, 30, 40, 50])
# Index with a list/array of indices
a[[0, 2, 4]] # [10 30 50]
a[np.array([0, 2, 4])] # same
# Indices can repeat
a[[0, 0, 1]] # [10 10 20]
# Negative indices work
a[[-1, -2]] # [50 40]
# 2-D fancy indexing
b = np.arange(12).reshape(3, 4)
rows = np.array([0, 2])
cols = np.array([1, 3])
b[rows, cols] # [1 11] pairs (0,1) and (2,3)
# Mix a slice and an index array
b[1:, [0, 2]] # rows 1-2, cols 0 and 2
# np.take: same as fancy indexing, more flexible
np.take(a, [0, 2]) # [10 30]Index Assignment & np.where
np.where(cond, x, y) is a vectorized if-else — extremely common for building arrays based on conditions. With a single argument it returns the indices where the condition is True. np.select handles multiple mutually-exclusive conditions, and np.clip is a clean way to bound values without writing explicit masks.
import numpy as np
a = np.array([1, 2, 3, 4, 5, 6])
# Conditional assignment with a mask
a[a % 2 == 0] = -1 # even -> -1: [1 -1 3 -1 5 -1]
# np.where(cond, x, y): vectorized ternary
b = np.where(a > 3, "big", "small")
# ['small' 'small' 'small' 'big' 'big' 'big']
# np.where with one argument -> indices of True
idx = np.where(a == -1)
# (array([1, 3, 5]),)
# np.select: multiple conditions, multiple choices
conds = [a < 3, a > 3, a == 3]
choices = ["low", "high", "mid"]
np.select(conds, choices, default="ok")
# np.clip: clamp values to a range
np.clip(a, 0, 4) # values < 0 -> 0, > 4 -> 4Reshaping & Shape Manipulation
reshape & -1 Inference
reshape changes the shape but keeps the same total size. Use -1 for one dimension and NumPy infers it from the size — only one -1 is allowed. reshape usually returns a view (no data copy), so mutating the reshaped array mutates the original. The total number of elements must remain constant or you get a ValueError.
import numpy as np
a = np.arange(12) # [0 1 2 ... 11], shape (12,)
# reshape to (3, 4)
b = a.reshape(3, 4)
# [[ 0 1 2 3]
# [ 4 5 6 7]
# [ 8 9 10 11]]
# Use -1 to infer one dimension
a.reshape(2, -1) # (2, 6) - -1 means "figure it out"
a.reshape(-1, 3) # (4, 3)
a.reshape(2, -1, 2) # (2, 3, 2) only one -1 allowed
# Total size must match -> ValueError otherwise
# a.reshape(5, 3) # 5*3 = 15 != 12
# reshape returns a view when possible (no copy)
b[0, 0] = 99
print(a[0]) # 99 <- a was modifiedFlatten & Ravel
ravel() returns a view (fast, shares memory) while flatten() always returns a copy (safe but slower and uses 2x memory). Use ravel when you just need to iterate, flatten when you'll mutate the result without affecting the original. The order argument matters when interoperating with Fortran code or MATLAB.
import numpy as np
a = np.array([[1, 2, 3],
[4, 5, 6]])
# ravel: returns a VIEW (C order by default) - efficient
a.ravel() # [1 2 3 4 5 6]
a.ravel(order='F') # [1 4 2 5 3 6] Fortran (column) order
# flatten: always returns a COPY - safe to mutate
flat = a.flatten()
flat[0] = 99
print(a[0, 0]) # 1 <- a unchanged
# Order conventions:
# 'C' (default): last axis varies fastest (row-major)
# 'F': first axis varies fastest (column-major)
# 'A': Fortran order if array is F-contiguous, else C
# 'K': preserve memory orderTranspose & Swap Axes
Transpose is just a view that reorders how memory is read — no data is copied, which makes it cheap but means the result may not be contiguous. For 2-D arrays .T is all you need; for 3+ dimensions use transpose(axis_order), swapaxes, or moveaxis to control exactly which axis goes where. Operations on non-contiguous arrays can be slower, so np.ascontiguousarray may help in hot loops.
import numpy as np
a = np.arange(12).reshape(3, 4)
a.T # shape (4, 3) transpose (2-D)
a.transpose() # same
# For 3+ dimensions, specify axis order
b = np.arange(24).reshape(2, 3, 4)
b.transpose(1, 0, 2) # shape (3, 2, 4) axes reordered
b.swapaxes(0, 1) # shape (3, 2, 4) swap two axes
b.T # shape (4, 3, 2) reverse all axes
# Move axes to new positions
np.moveaxis(b, 0, -1) # shape (3, 4, 2)
# All of these return VIEWS (no data copy)Stacking Arrays
stack adds a NEW axis (turns two 1-D arrays into a 2-D); concatenate joins along an EXISTING axis. vstack/hstack/dstack are convenience wrappers for 1-D and 2-D arrays. For complex layouts, np.block accepts nested lists of arrays like building a matrix from sub-blocks. All stacking functions create new arrays (copies).
import numpy as np
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
# Stack along a NEW axis
np.stack([a, b]) # shape (2, 3) - default axis=0
np.stack([a, b], axis=1) # shape (3, 2) column stack
# Concatenate along an EXISTING axis
np.concatenate([a, b]) # [1 2 3 4 5 6]
np.vstack([a, b]) # shape (2, 3) vertical
np.hstack([a, b]) # [1 2 3 4 5 6] horizontal
np.dstack([a, b]) # shape (1, 3, 2) depth
# 2-D arrays
A = np.ones((2, 2))
B = np.zeros((2, 2))
np.hstack([A, B]) # shape (2, 4)
np.vstack([A, B]) # shape (4, 2)
# np.block: build from nested blocks
np.block([[A, B], [B, A]]) # shape (4, 4)Splitting Arrays
split requires equal-sized chunks (raises ValueError otherwise) while array_split allows uneven division — handy when N doesn't divide evenly. vsplit/hsplit/dsplit are axis-specific helpers equivalent to np.split(arr, n, axis=...). All return lists of views into the original array, so they're memory-efficient.
import numpy as np
a = np.arange(12)
# Split into equal parts (must divide evenly)
np.split(a, 3) # [array([0,1,2,3]), array([4,5,6,7]), array([8,9,10,11])]
# Split at specific indices
np.split(a, [3, 5]) # [array([0,1,2]), array([3,4]), array([5,6,7,8,9,10,11])]
# 2-D
b = np.arange(16).reshape(4, 4)
np.vsplit(b, 2) # split along rows -> 2 arrays of shape (2, 4)
np.hsplit(b, 2) # split along cols -> 2 arrays of shape (4, 2)
# array_split: allows uneven division
np.array_split(np.arange(10), 3) # sizes [4, 3, 3] - no error
# dsplit: split along depth (axis 2)
c = np.arange(24).reshape(2, 2, 6)
np.dsplit(c, 3) # 3 arrays of shape (2, 2, 2)Broadcasting
Broadcasting Rules
Broadcasting lets you combine arrays of different shapes by virtually expanding axes of size 1 — no data is actually copied. The rule: compare shapes from the right; each axis must be equal or one of them must be 1 (or absent). This is what makes NumPy code concise (no explicit loops) but it's also a common source of silent shape bugs — always check shapes when something looks wrong.
import numpy as np
# Broadcasting: operate on arrays of different shapes without copying.
# Two shapes are compatible if, for each trailing axis, they are
# equal OR one of them is 1.
# Scalar broadcast to any array
a = np.array([1, 2, 3])
a + 10 # [11 12 13] 10 treated as shape ()
# 1-D with 2-D
A = np.ones((3, 4))
row = np.array([10, 20, 30, 40])
A + row # row (4,) broadcast to (3, 4) -> added to each row
col = np.array([[100], [200], [300]]) # shape (3, 1)
A + col # col broadcast across columns -> added to each column
# Incompatible shapes raise ValueError
# A + np.array([1, 2, 3]) # (3,4) vs (3,) -> errorAdding Axes for Broadcasting
np.newaxis (alias None) inserts an axis of size 1, the standard trick for reshaping arrays so they broadcast the way you want. Adding a column axis (a[:, None]) and a row axis (a[None, :]) lets you compute outer products, pairwise differences, and more without writing loops. np.expand_dims is the named-function equivalent for readability.
import numpy as np
a = np.array([1, 2, 3]) # shape (3,)
b = np.array([10, 20, 30, 40]) # shape (4,)
# a + b would fail (3,) vs (4,)
# Insert a new axis so shapes become (3, 1) and (4,) -> broadcasts to (3, 4)
a_col = a[:, np.newaxis] # shape (3, 1) - same as a.reshape(-1, 1)
b_row = b[np.newaxis, :] # shape (1, 4)
outer = a_col + b_row # shape (3, 4) outer sum
# [[11 21 31 41]
# [12 22 32 42]
# [13 23 33 43]]
# np.newaxis / None are equivalent
a[:, None] # same as a[:, np.newaxis]
# np.expand_dims is the explicit function form
np.expand_dims(a, axis=1) # shape (3, 1)Outer Products & Pairwise Ops
Adding a new axis to convert a 1-D operation into a 2-D outer-style computation is the key idiom of vectorized NumPy. a[:, None] - b[None, :] computes all pairwise differences without loops — far faster than nested Python loops. This pattern powers distance matrices, kernel computations, and grid operations.
import numpy as np
a = np.array([1, 2, 3])
b = np.array([10, 20, 30, 40])
# Outer product (3,) x (4,) -> (3, 4)
np.outer(a, b)
# [[10 20 30 40]
# [20 40 60 80]
# [30 60 90 120]]
# Manual outer via broadcasting
a[:, None] * b[None, :] # same as np.outer(a, b)
# Pairwise difference: (4,) - (3,) -> (4, 3) or (3, 4)?
diff = b[:, None] - a[None, :] # shape (4, 3): diff[i, j] = b[i] - a[j]
# Pairwise equality (used in set ops, distance)
x = np.array([1, 2, 3, 4])
y = np.array([2, 4, 6])
x[:, None] == y[None, :] # shape (4, 3) boolean matrix
# Add a constant vector to each row of a matrix
M = np.zeros((5, 3))
v = np.array([1, 2, 3])
M + v # broadcasts v to every rowCommon Broadcasting Pitfalls
A 1-D array of shape (N,) broadcasts as a ROW against a 2-D array — it is NOT a column vector. To make it a column you must add an axis: a[:, None]. Watch out for unintended broadcasting when you add new axes — two (1000, 1000) arrays can explode into a (1000, 1000, 1000) intermediate (8 GB) and crash your program. Always verify shapes with .shape before running big computations.
import numpy as np
# Pitfall 1: (N,) is NOT a row or column vector - it has no axis direction
a = np.array([1, 2, 3])
M = np.ones((3, 3))
M + a # OK: (3,3) + (3,) -> a broadcast as a ROW
M + a[:, None] # OK: a as a COLUMN
# Pitfall 2: accidentally broadcasting when you wanted element-wise
x = np.array([1, 2, 3])
y = np.array([10, 20, 30])
x + y # [11 22 33] element-wise (both (3,))
x[:, None] + y # (3,3) matrix - probably not what you wanted!
# Pitfall 3: shape (1,) vs () - both broadcast but mean different things
np.array([5]).shape # (1,)
np.array(5).shape # () - 0-D, broadcasts to ANYTHING
# Pitfall 4: memory blow-up from over-broadcasting
# Big arrays broadcast together can produce enormous intermediates
A = np.ones((1000, 1000))
B = np.ones((1000, 1000))
(A * B).sum() # OK - intermediate is 8MB
# A[:, None, :] * B[None, :, :] # (1000,1000,1000) = 8GB! crashBroadcasting with np.broadcast_to
np.broadcast_to lets you explicitly create a broadcasted view — useful when an API requires a specific shape but you want to avoid the memory cost of a full copy. The result is read-only because the underlying data is smaller than the shape suggests. np.broadcast_arrays is handy when you want to iterate over several arrays as if they had the same shape.
import numpy as np
# np.broadcast_to: explicit view of an array broadcast to a new shape
a = np.array([1, 2, 3]) # shape (3,)
b = np.broadcast_to(a, (4, 3)) # shape (4, 3) - VIEW, no copy
# [[1 2 3]
# [1 2 3]
# [1 2 3]
# [1 2 3]]
b[0, 0] = 99 # ERROR: broadcast_to returns read-only view
# np.broadcast_arrays: broadcast several arrays together (as views)
x = np.array([1, 2, 3]) # (3,)
y = np.array([[10], [20]]) # (2, 1)
bx, by = np.broadcast_arrays(x, y)
bx.shape # (2, 3)
by.shape # (2, 3)
# np.broadcast: low-level iterator object
b = np.broadcast(x, y)
b.shape # (2, 3)
b.index # current position in the iteration
b.iters # list of iterators over each inputMathematical Operations
Element-wise Arithmetic
NumPy operators (+, *, **) map to ufuncs (np.add, np.multiply, np.power) and operate element-wise. The * operator is element-wise, NOT matrix multiplication — use @ or np.dot for that. In-place operators (+=, *=) save memory by reusing the array's buffer; they require matching dtype and shape (after broadcasting).
import numpy as np
a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])
a + b # [11 22 33 44]
a - b # [-9 -18 -27 -36]
a * b # [10 40 90 160] element-wise (NOT matrix product)
a / b # [0.1 0.1 0.1 0.1] always float
a // b # [0 0 0 0] floor division
a ** 2 # [1 4 9 16] power
a % 2 # [1 0 1 0] modulo
-a # [-1 -2 -3 -4] negation
# In-place ops (modify a, save memory)
a += 10
a *= 2
# Scalar ops broadcast
a + 100 # [102 104 106 108]
# Universal functions (ufuncs) - same operations as function form
np.add(a, b)
np.multiply(a, b)
np.power(a, 2)Universal Functions (ufuncs)
ufuncs are vectorized functions that operate element-wise and run in compiled C — much faster than applying Python's math module in a loop. np.maximum (element-wise max of two arrays) is different from np.max (max over one array). Use np.fmax to ignore NaN in comparisons. All ufuncs support out= to write into a pre-allocated buffer for zero-allocation hot loops.
import numpy as np
a = np.array([-1.5, 0.0, 1.5, 2.5])
# Math ufuncs
np.abs(a) # [1.5 0. 1.5 2.5]
np.sqrt(np.abs(a))
np.exp(a) # e^a
np.log(np.abs(a) + 1)
np.log2(a + 5)
np.log10(a + 5)
# Rounding
np.round(a) # [-2. 0. 2. 2.]
np.floor(a) # [-2. 0. 1. 2.]
np.ceil(a) # [-1. 0. 2. 3.]
np.trunc(a) # [-1. 0. 1. 2.] truncate toward zero
# Trigonometry (radians)
np.sin(np.pi/2) # 1.0
np.cos(0) # 1.0
np.degrees(np.pi) # 180.0
np.radians(180) # 3.14159...
# Comparison ufuncs
np.greater(a, 0) # [False False True True]
np.maximum(a, 0) # clip negatives to 0 (element-wise max)
np.minimum(a, 0)Reduction Operations
Reductions (sum, mean, max, min, std) operate over the whole array by default, or along a single axis with axis=. axis=0 reduces rows (collapses down), axis=1 reduces columns (collapses across). Use keepdims=True to keep the reduced axis as size 1 — essential for broadcasting the result back. Always pass dtype=np.float64 when summing integers that might overflow.
import numpy as np
a = np.array([[1, 2, 3],
[4, 5, 6]])
# Global reductions
a.sum() # 21
a.prod() # 720
a.min(), a.max()
a.mean() # 3.5
a.std(), a.var()
a.argmin(), a.argmax() # indices of min/max (flattened)
# Axis-wise reductions
a.sum(axis=0) # [5 7 9] sum down each column
a.sum(axis=1) # [6 15] sum across each row
a.min(axis=1) # [1 4] min of each row
a.cumsum(axis=0) # cumulative sum down columns
a.cumprod(axis=1) # cumulative product across rows
# Keep dimensions
a.sum(axis=0, keepdims=True) # shape (1, 3) - keeps the axis
# Specify dtype for accumulation (avoid overflow!)
a.sum(dtype=np.float64)
# NaN-aware versions
b = np.array([1.0, np.nan, 3.0])
np.nansum(b) # 4.0 treats NaN as 0Cumulative & Scan Operations
cumsum / cumprod keep the same shape as the input and return running totals. diff is the discrete derivative and is the inverse of cumsum. Beware integer overflow in cumsum — accumulating int32 values can wrap around silently; promote to int64 or float to be safe. The .accumulate method on any ufunc (np.maximum.accumulate) gives a running reduction.
import numpy as np
a = np.array([1, 2, 3, 4, 5])
# Cumulative sum and product
np.cumsum(a) # [ 1 3 6 10 15]
np.cumprod(a) # [ 1 2 6 24 120]
# 2-D: specify axis
b = np.array([[1, 2], [3, 4]])
np.cumsum(b, axis=0) # [[1 2]
# [4 6]]
np.cumsum(b, axis=1) # [[1 3]
# [3 7]]
# Differences (inverse of cumulative sum)
np.diff(a) # [1 1 1 1] a[i+1] - a[i]
np.diff(a, n=2) # second-order diff [0 0 0]
# Edge: cumsum on integers can overflow silently
big = np.array([2**30, 2**30, 2**30], dtype=np.int32)
np.cumsum(big) # wraps around - use int64 or float!
# Cumulative max / min
np.maximum.accumulate(a) # [1 2 3 4 5]
np.minimum.accumulate(a) # [1 1 1 1 1]Comparison & Logic Operations
Use & | ~ on boolean arrays (not and/or — those require a single truth value). np.any / np.all reduce a boolean array to a single True/False, useful in assertions and conditionals. Never use == for floats — np.isclose or np.allclose with appropriate tolerances (atol/rtol) handle floating-point rounding. allclose is the standard for comparing arrays of floats.
import numpy as np
a = np.array([1, 2, 3, 4, 5])
# Element-wise comparisons return boolean arrays
a > 3 # [False False False True True]
a == 3 # [False False True False False]
a != 3
np.greater(a, 3)
# Logical ops on boolean arrays
b = (a > 2)
c = (a < 5)
np.logical_and(b, c) # [False False True True False]
np.logical_or(b, c)
np.logical_not(b)
np.logical_xor(b, c)
# Array-level truth testing
np.any(a > 4) # True
np.all(a > 0) # True
np.all(a > 0, axis=0) # per-axis
(a == 3).any() # True
# np.isclose for float comparisons
np.isclose(0.1 + 0.2, 0.3) # True (handles float error)
np.allclose([0.1, 0.2], [0.1, 0.2 + 1e-9], atol=1e-8) # TrueStatistical Operations
Basic Statistics
var/std default to ddof=0 (population), but pandas defaults to ddof=1 (sample) — a frequent source of mismatch. Use ddof=1 for sample statistics. np.percentile takes values 0-100, np.quantile takes 0.0-1.0. np.corrcoef returns the full correlation matrix; index [0, 1] for the scalar coefficient between two arrays.
import numpy as np
a = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
a.mean() # 5.5 arithmetic mean
a.median() if hasattr(a, 'median') else np.median(a) # 5.5
a.var() # 8.25 variance (population, ddof=0)
a.std() # 2.87 standard deviation
a.min(), a.max() # 1, 10
a.ptp() # 9 peak-to-peak = max - min
# Sample vs population variance
a.var(ddof=1) # sample variance (divide by N-1, like pandas default)
# Percentiles & quantiles
np.percentile(a, 50) # 5.5 median
np.percentile(a, [25, 50, 75]) # [3.25 5.5 7.75] quartiles
np.quantile(a, 0.5) # 5.5 same as percentile / 100
# Correlation
x = np.array([1, 2, 3, 4])
y = np.array([2, 4, 5, 4])
np.corrcoef(x, y) # 2x2 correlation matrixHistograms & Binning
np.histogram returns counts and edges (edges has one more element than bins). Use density=True to get a probability density (area = 1) instead of raw counts. np.histogram2d builds a 2-D histogram useful for heatmaps and joint distributions. np.digitize maps values to bin indices; np.bincount is a fast way to count integer occurrences.
import numpy as np
data = np.random.default_rng(42).standard_normal(1000)
# 1-D histogram
counts, edges = np.histogram(data, bins=10)
# counts: array of element counts per bin
# edges: array of bin edge positions (length = bins + 1)
# Custom bin edges
np.histogram(data, bins=[-3, -1, 0, 1, 3])
# Density (integrates to 1) vs counts
np.histogram(data, bins=20, density=True)
# 2-D histogram (for heatmaps)
x = np.random.default_rng(1).standard_normal(1000)
y = np.random.default_rng(2).standard_normal(1000)
H, xedges, yedges = np.histogram2d(x, y, bins=20)
# Digitize: which bin does each value fall into?
bins = [-2, -1, 0, 1, 2]
indices = np.digitize(data, bins) # array of bin indices (1-based)
# Bincount: count occurrences of each non-negative int
np.bincount(np.array([0, 1, 1, 3, 3, 3])) # [1 2 0 3]Sorting & Order Statistics
argsort is the key to ranking: it returns the indices that would sort the array, letting you reorder another array the same way. np.partition is much faster than a full sort when you only need the top-k elements (O(n) vs O(n log n)). np.unique returns sorted unique values; pass return_counts=True for a histogram-like summary.
import numpy as np
a = np.array([3, 1, 4, 1, 5, 9, 2, 6])
# Sorting
np.sort(a) # [1 1 2 3 4 5 6 9] returns a copy
a.sort() # in-place sort (modifies a)
np.sort(a)[::-1] # descending
np.argsort(a) # indices that would sort a (no copy)
# 2-D sorting along axis
b = np.array([[3, 1, 2], [6, 5, 4]])
np.sort(b, axis=0) # sort down each column
np.sort(b, axis=1) # sort across each row (default)
# Partial sorting: top-k smallest (no full sort)
np.partition(a, 3) # 3 smallest first (unordered), rest after
np.argpartition(a, 3) # indices of partition
# k-th smallest element (selection algorithm)
np.partition(a, 2)[2] # 3rd smallest (index 2)
# unique values (sorted)
np.unique([1, 2, 1, 3, 2]) # [1 2 3]
np.unique(a, return_counts=True) # values and countsCovariance & Correlation
np.cov treats each ROW as a variable by default (rowvar=True) — the opposite of pandas, which uses columns. To match pandas behavior, pass rowvar=False or transpose your data. np.corrcoef returns the full matrix; index [0, 1] to get the scalar coefficient between two variables. The diagonal of the covariance matrix is the variance of each variable.
import numpy as np
# Sample data: 3 variables, 5 observations
data = np.array([[1, 2, 3, 4, 5],
[2, 4, 1, 3, 5],
[5, 4, 3, 2, 1]], dtype=float)
# Covariance matrix (3x3)
# Each ROW is a variable, so set rowvar=False to treat COLUMNS as variables
cov = np.cov(data, rowvar=True) # 3x3 covariance between rows
# Diagonal = variance of each variable
# Off-diagonal = covariance between pairs
# Correlation matrix
corr = np.corrcoef(data) # 3x3, values in [-1, 1]
# Correlation between two specific variables
np.corrcoef(data[0], data[1])[0, 1] # scalar
# Weighted covariance: compute manually
weights = np.array([1, 1, 1, 2, 2])
mean = np.average(data, axis=1, weights=weights, keepdims=True)
centered = data - mean
weighted_cov = (centered * weights) @ centered.T / weights.sum()
# Pearson correlation coefficient (manual)
def pearson(x, y):
xm, ym = x - x.mean(), y - y.mean()
return np.dot(xm, ym) / (np.sqrt((xm**2).sum()) * np.sqrt((ym**2).sum()))NaN-aware Statistics
Standard reductions propagate NaN (any NaN poisons the result), which is rarely what you want. Use the nan* variants (nanmean, nansum, nanstd) to ignore NaN values. nan_to_num replaces NaN and infinities with finite values — perfect for cleaning data before computation. For heavy NaN handling, pandas is often more ergonomic than raw NumPy.