Skip to content

Structured Arrays & Random Walks

What you’ll learn

  • Build a structured array — an ndarray whose elements are records with named fields
  • Access and nest structured fields, and mix them with regular NumPy math
  • Turn a step-by-step Python loop into one vectorized random walk expression
  • Simulate thousands of random walks at once and measure “first crossing time”

Structured arrays: rows with named fields

A regular ndarray is homogeneous — every element is the same plain type (all int64int64, all float64float64, and so on). A structured array relaxes that: each element is a small record made of several named fields, possibly of different types, while the whole thing still lives in one contiguous, fast NumPy array.

Think of it as a lightweight table — closer to a list of dictdicts than a plain array, but still backed by NumPy’s fast memory layout instead of ordinary Python objects.

structured-basic
import numpy as np
 
dtype = [("name", "U10"), ("age", "i4"), ("height", "f4")]
people = np.array(
    [("Bob", 25, 5.9), ("Sue", 30, 5.5)],
    dtype=dtype,
)
 
print(people)
print(people["name"])   # every "name" field, as its own array
print(people["age"])    # every "age" field, as its own array
print(people[0])        # one whole record
structured-basic
import numpy as np
 
dtype = [("name", "U10"), ("age", "i4"), ("height", "f4")]
people = np.array(
    [("Bob", 25, 5.9), ("Sue", 30, 5.5)],
    dtype=dtype,
)
 
print(people)
print(people["name"])   # every "name" field, as its own array
print(people["age"])    # every "age" field, as its own array
print(people[0])        # one whole record

Each field name maps to a type code, just like the dtypes you already know: "U10""U10" is a 10-character Unicode string, "i4""i4" a 32-bit integer, "f4""f4" a 32-bit float. Because people["age"]people["age"] is a normal ndarray, every NumPy method still works on it:

structured-math
import numpy as np
 
dtype = [("name", "U10"), ("age", "i4"), ("height", "f4")]
people = np.array(
    [("Bob", 25, 5.9), ("Sue", 30, 5.5)],
    dtype=dtype,
)
 
print(people["age"].mean())
structured-math
import numpy as np
 
dtype = [("name", "U10"), ("age", "i4"), ("height", "f4")]
people = np.array(
    [("Bob", 25, 5.9), ("Sue", 30, 5.5)],
    dtype=dtype,
)
 
print(people["age"].mean())

Nested dtypes

Fields can themselves be structured, letting you group related values — for example, a 2D point packed inside a labeled record:

nested-dtype
import numpy as np
 
point_dtype = np.dtype([("x", "f8"), ("y", "f8")])
labeled_dtype = np.dtype([("point", point_dtype), ("label", "U5")])
 
data = np.array([((1.0, 2.0), "A"), ((3.0, 4.0), "B")], dtype=labeled_dtype)
 
print(data["point"])       # the (x, y) sub-array for every record
print(data["point"]["x"])  # just the x values
nested-dtype
import numpy as np
 
point_dtype = np.dtype([("x", "f8"), ("y", "f8")])
labeled_dtype = np.dtype([("point", point_dtype), ("label", "U5")])
 
data = np.array([((1.0, 2.0), "A"), ((3.0, 4.0), "B")], dtype=labeled_dtype)
 
print(data["point"])       # the (x, y) sub-array for every record
print(data["point"]["x"])  # just the x values
diagram A structured array's anatomy mermaid
One structured array packs several named, independently-typed fields per record, but each field still behaves like its own ndarray.

Structured arrays are handy for reading fixed-layout binary files (sensor logs, C structs) or when you want table-like data without bringing in pandas. Speaking of pandas: once your data has named, mixed-type columns like this, a DataFrame is usually a nicer tool for everyday analysis — structured arrays are the low-level building block underneath.

From a Python loop to an array expression: random walks

Random walks are a great way to see array-oriented thinking pay off. A random walk starts at 00 and, at every step, moves +1+1 or -1-1 with equal probability. Here’s the obvious way to write it, one step at a time:

walk-pure-python
import random
 
position = 0
walk = [position]
nsteps = 1000
 
for _ in range(nsteps):
    step = 1 if random.randint(0, 1) else -1
    position += step
    walk.append(position)
walk-pure-python
import random
 
position = 0
walk = [position]
nsteps = 1000
 
for _ in range(nsteps):
    step = 1 if random.randint(0, 1) else -1
    position += step
    walk.append(position)

Look closely and walkwalk is nothing but the cumulative sum of the steps. That’s the insight that lets us drop the loop entirely: draw all the coin flips at once, turn them into +1+1/-1-1 with np.wherenp.where, then call cumsumcumsum:

walk-vectorized
import numpy as np
 
nsteps = 1000
rng = np.random.default_rng(seed=12345)
 
draws = rng.integers(0, 2, size=nsteps)     # 0s and 1s
steps = np.where(draws == 0, 1, -1)         # turn coin flips into +1 / -1
walk = steps.cumsum()                       # running position at each step
 
print(walk.min(), walk.max())
walk-vectorized
import numpy as np
 
nsteps = 1000
rng = np.random.default_rng(seed=12345)
 
draws = rng.integers(0, 2, size=nsteps)     # 0s and 1s
steps = np.where(draws == 0, 1, -1)         # turn coin flips into +1 / -1
walk = steps.cumsum()                       # running position at each step
 
print(walk.min(), walk.max())
diagram Building a random walk without a loop mermaid
Coin flips become steps, and cumsum turns steps into a position — the whole simulation is three vectorized calls.

First crossing time

A more interesting statistic than min/max is the first crossing time: how many steps did it take the walk to get at least 10 away from zero, in either direction? np.abs(walk) >= 10np.abs(walk) >= 10 gives a boolean array marking every step where that’s true; argmaxargmax on a boolean array returns the index of its first TrueTrue (since TrueTrue counts as the maximum value):

first-crossing
import numpy as np
 
nsteps = 1000
rng = np.random.default_rng(seed=12345)
draws = rng.integers(0, 2, size=nsteps)
steps = np.where(draws == 0, 1, -1)
walk = steps.cumsum()
 
crossed_10 = (np.abs(walk) >= 10).argmax()
print(crossed_10)
first-crossing
import numpy as np
 
nsteps = 1000
rng = np.random.default_rng(seed=12345)
draws = rng.integers(0, 2, size=nsteps)
steps = np.where(draws == 0, 1, -1)
walk = steps.cumsum()
 
crossed_10 = (np.abs(walk) >= 10).argmax()
print(crossed_10)
sketch A single random walk, one step at a time p5.js
Each step nudges the position up or down by one; np.where + cumsum builds this whole path in one shot, without any Python loop.

Simulating many random walks at once

The vectorized version scales effortlessly. Pass a (nwalks, nsteps)(nwalks, nsteps) tuple to rng.integersrng.integers and you get a 2D array of draws — one row per walk. cumsum(axis=1)cumsum(axis=1) then runs every walk’s cumulative sum along its own row, all in one call:

many-walks
import numpy as np
 
nwalks = 5000
nsteps = 1000
rng = np.random.default_rng(seed=12345)
 
draws = rng.integers(0, 2, size=(nwalks, nsteps))
steps = np.where(draws > 0, 1, -1)
walks = steps.cumsum(axis=1)
 
print(walks.max(), walks.min())
many-walks
import numpy as np
 
nwalks = 5000
nsteps = 1000
rng = np.random.default_rng(seed=12345)
 
draws = rng.integers(0, 2, size=(nwalks, nsteps))
steps = np.where(draws > 0, 1, -1)
walks = steps.cumsum(axis=1)
 
print(walks.max(), walks.min())

Not every walk necessarily wanders as far as 30 steps from zero. any(axis=1)any(axis=1) collapses each row down to a single boolean — “did this walk ever reach 30 or -30?” — and boolean .sum().sum() counts how many walks did:

hits-30
import numpy as np
 
nwalks = 5000
nsteps = 1000
rng = np.random.default_rng(seed=12345)
draws = rng.integers(0, 2, size=(nwalks, nsteps))
steps = np.where(draws > 0, 1, -1)
walks = steps.cumsum(axis=1)
 
hits30 = (np.abs(walks) >= 30).any(axis=1)
print(hits30.sum())   # how many of the 5,000 walks reached +-30
hits-30
import numpy as np
 
nwalks = 5000
nsteps = 1000
rng = np.random.default_rng(seed=12345)
draws = rng.integers(0, 2, size=(nwalks, nsteps))
steps = np.where(draws > 0, 1, -1)
walks = steps.cumsum(axis=1)
 
hits30 = (np.abs(walks) >= 30).any(axis=1)
print(hits30.sum())   # how many of the 5,000 walks reached +-30

Finally, filter down to just the walks that crossed, and run argmax(axis=1)argmax(axis=1) on each remaining row to get every one’s individual crossing time:

crossing-times
import numpy as np
 
nwalks = 5000
nsteps = 1000
rng = np.random.default_rng(seed=12345)
draws = rng.integers(0, 2, size=(nwalks, nsteps))
steps = np.where(draws > 0, 1, -1)
walks = steps.cumsum(axis=1)
 
hits30 = (np.abs(walks) >= 30).any(axis=1)
crossing_times = (np.abs(walks[hits30]) >= 30).argmax(axis=1)
print(crossing_times.mean())
crossing-times
import numpy as np
 
nwalks = 5000
nsteps = 1000
rng = np.random.default_rng(seed=12345)
draws = rng.integers(0, 2, size=(nwalks, nsteps))
steps = np.where(draws > 0, 1, -1)
walks = steps.cumsum(axis=1)
 
hits30 = (np.abs(walks) >= 30).any(axis=1)
crossing_times = (np.abs(walks[hits30]) >= 30).argmax(axis=1)
print(crossing_times.mean())

Next

Continue to: Introduction to Pandas to bring this same array-oriented thinking to labeled, tabular data.

🧪 Try It Yourself

Exercise 1 – Access a Structured Array’s Field

Exercise 2 – Build a Random Walk with where + cumsum

Exercise 3 – Find the First Crossing Time

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did