A computer’s random numbers come from a rule and a starting state, the seed. Build a tiny generator by hand, then use NumPy’s default_rng, see why every call moves its state, and seed pandas and scikit-learn so that a shuffle or a split comes out the same every time.
P02.9.2L1 Core25 minIn review (what this means)
The maths and code on this page are checked by automated tests on every change, but an independent reader has not signed it off yet. Spot a mistake or a confusing step? Tell us.
Compute the first outputs of the generator x → (5x + 3) mod 16 from a seed by hand.
Explain why a generator with m states must repeat within m steps, and why the same seed gives the same sequence.
Make one generator with np.random.default_rng(0) and pass it on, and explain why the order of calls changes the numbers each call gets.
Seed DataFrame.sample and train_test_split with random_state, and report a result over several seeds.
The question
A model is trained on a random 75% of the iris flowers and tested on the rest. Rerun the notebook and a different 75% is drawn, so the accuracy changes. How can a program make numbers that look random yet come out the same on every run, and why does running one cell twice still change them?
From the last lesson
1. In a fresh kernel, the cells x = 10, x = x + 1, y = x * 2 are run with the middle cell twice. What are \(x\) and \(y\)?
Show answer
\(x = 12\) and \(y = 24\); a clean run gives \(11\) and \(22\).
2. What does restarting the kernel do?
Show answer
It empties the kernel’s dictionary of names and sets the execution counter back to zero, so the next run starts with no hidden state.
Intuition
A computer follows rules; it cannot toss a coin. What it can do is keep a number, its state, and apply a fixed rule to it every time a random number is asked for. The new state is the output, and it is also the input for next time. A well-chosen rule makes the outputs look patternless.
The first state is the seed. Start from the same seed and the rule walks the same path, so every number comes out the same. That is what makes a random experiment repeatable.
P02.9.2-F1
A toy generator. The rule fixes the ring; the seed fixes where you start on it. Every number after the seed is decided before the first one is drawn.
Such numbers are called pseudo-random: produced by a rule, but good enough to stand in for chance in a simulation, a shuffle or a train-test split.
Build it up
Step 1. A rule. Take \(x \to {(5x + 3)} \bmod 16\). “mod 16” keeps the remainder after dividing by 16, so every state is a whole number from 0 to 15.
Step 2. A seed. Start at \(x = 7\). The rule gives 6, then 1, then 8, then 11.
Step 3. Numbers between 0 and 1. Most uses want a number in \([0, 1)\). Divide the state by 16: 6, 1, 8, 11 become 0.375, 0.0625, 0.5 and 0.6875.
Step 4. Each draw moves the state. The generator does not know who is asking. Whichever line of code draws next gets the next number. Suppose three cells A, B and C each draw one number. In a clean run A gets 6, B gets 1 and C gets 8. Run cell A twice and it takes 6 and then 1, so B gets 8 and C gets 11.
P02.9.2-F2
The generator is one more piece of kernel state. Running a cell twice moves it twice, and every later draw shifts along.
So the seed alone does not fix the numbers in a notebook. The seed and the order of every draw do, which brings back the lesson of P02.9.1: Restart and Run All.
The formal statement
NoteDefinition: linear congruential generator
Choose whole numbers \(m > 0\) (the modulus), \(a\) (the multiplier) and \(c\) (the increment), and a seed \(x_0\) with \(0 \le x_0 < m\). The linear congruential generator makes the sequence
\[
\begin{aligned}
x_{k+1} &= (a\,x_k + c) \bmod m, \\
k &= 0, 1, 2, \dots
\end{aligned}
\]
The toy generator of this lesson has \(a = 5\), \(c = 3\), \(m = 16\).
Here \(c\) is the increment, as in the usual statement of the rule; it is not the decision threshold of later courses.
Claim 1: the same seed gives the same sequence.\(x_{k+1}\) is computed from \(x_k\) alone. So \(x_1\) is fixed by \(x_0\), then \(x_2\) by \(x_1\), and so on: every term is fixed by the seed.
Claim 2: the sequence repeats within \(m\) steps. Look at the \(m + 1\) states \(x_0, x_1, \dots, x_m\). There are only \(m\) possible values, so two of them are equal, say \(x_i = x_j\) with \(i < j \le m\). By Claim 1, equal states have equal successors, so \(x_{i+1} = x_{j+1}\), then \(x_{i+2} = x_{j+2}\), and so on. From step \(i\) the sequence repeats with a period of \(j - i\), at most \(m\).
The toy generator reaches the longest possible period, 16: from the seed 7 it visits every state once before 7 returns. Not every choice of \(a\) and \(c\) does.
P02.9.2-F5
Two rules on the same 16 states, both started at 7. The multiplier 3 cuts the ring in half: from 7 it never reaches the other eight states.
Go deeper: when does a generator visit every state?
For an increment \(c \neq 0\), the Hull–Dobell theorem says that the period is \(m\) for every seed exactly when three conditions hold:
\(c\) and \(m\) have no common factor above 1;
\(a - 1\) is divisible by every prime factor of \(m\);
if \(m\) is divisible by 4, so is \(a - 1\).
For \(a = 5\), \(c = 3\), \(m = 16\): 3 and 16 share no factor; the only prime factor of 16 is 2, and \(a - 1 = 4\) is even; and 4 divides both 16 and 4. All three hold, so the period is 16. For \(a = 3\), \(a - 1 = 2\) is not divisible by 4, the third condition fails, and the 16 states split into two rings of 8.
A full period does not make a generator good: the toy one alternates odd and even outputs. NumPy’s default generator, PCG64, keeps a 128-bit state that a linear congruential generator moves on, and hands out not the state itself but 64 bits mixed from it.
Worked example
Run \(x \to {(5x + 3)} \bmod 16\) for four steps from the seed 7, by hand.
To take a number mod 16, write it as a multiple of 16 plus a remainder \(r\) between 0 and 15; the remainder is the answer.
P02.9.2-F3
Four steps by hand. Each output is fed straight back in as the next state.
As numbers in \([0, 1)\) they are \(6/16 = 0.375\), \(1/16 = 0.0625\), \(8/16 = 0.5\) and \(11/16 = 0.6875\). Carry on and the states run 10, 5, 12, 15, 14, 9, 0, 3, 2, 13, 4 and then 7 again: sixteen steps, every state once.
Code
The toy generator, and a check of its period.
def lcg(seed, steps, a=5, c=3, m=16):"""The states after the seed of x -> (a x + c) mod m.""" out, x = [], seedfor _ inrange(steps): x = (a * x + c) % m out.append(x)return outprint(lcg(7, 4), [x /16for x in lcg(7, 4)])print(lcg(7, 16))print("states visited:", len(set(lcg(7, 16))), " with a = 3:", sorted(set(lcg(7, 16, a=3))))
NumPy’s generator works the same way, with a far larger state. Make it once with np.random.default_rng(seed) and ask it for numbers through its methods.
import numpy as nprng = np.random.default_rng(0)first = rng.integers(1, 7, size=5) # five dicesecond = rng.integers(1, 7, size=5) # the state has moved: five new diceagain = np.random.default_rng(0).integers(1, 7, size=5) # a new generator, same seedprint(first, second, again)print(np.random.default_rng(0).normal(size=3))print(np.random.default_rng(0).permutation(4))
first and again match; second does not, because the first call moved the state. The same happens across cells. Here cell A draws noise and cell B shuffles four rows; running A twice hands B a different shuffle.
def notebook_run(runs_of_a): rng = np.random.default_rng(0) # the one generator, made at the topfor _ inrange(runs_of_a): noise = rng.normal(size=4) # cell Areturn rng.permutation(4) # cell Bprint("clean run:", notebook_run(1), " A run twice:", notebook_run(2))
clean run: [1 2 0 3] A run twice: [0 2 1 3]
pandas and scikit-learn take a seed through an argument called random_state. The same seed gives the same rows.
With the default test_size, a quarter of the 150 flowers, 38, go to the test set. The seed fixes which 38. A result that depends on one lucky split is not a result, so report it over several seeds. A very small classifier shows this: predict the species whose mean petal length, on the training rows, is closest to a test flower’s petal length.
def accuracy(seed): X_tr, X_te, y_tr, y_te = train_test_split(X, y, random_state=seed) centre = X_tr["petal length (cm)"].groupby(y_tr).mean().to_numpy() length = X_te["petal length (cm)"].to_numpy() pred = np.abs(length[:, None] - centre[None, :]).argmin(axis=1)returnint((pred == y_te.to_numpy()).sum())right = [accuracy(seed) for seed inrange(5)]print("correct out of 38, seeds 0 to 4:", right)print(f"mean accuracy {np.mean(right) /38:.3f}, from {min(right) /38:.3f} to {max(right) /38:.3f}")
correct out of 38, seeds 0 to 4: [36, 37, 36, 35, 37]
mean accuracy 0.953, from 0.921 to 0.974
On the pinned library versions, the five splits score 35 to 37 out of 38. “About 95%, between 92% and 97% over five splits” is an honest report; “97%” alone would quote the luckiest seed.
Explore
This module’s interactive, the run-order sandbox, is in lesson P02.9.1. Read its cell x = x + 1 as “draw the next number”: running it twice moves the state twice, exactly as running cell A twice moved the generator above, and Restart and Run All puts both back to a clean run.
Pitfalls
P02.9.2-F4
Wrong beside right. Re-seeding inside the loop gives the same “random” order every epoch; one generator made once gives a new order each epoch, and the whole run still repeats after a restart.
“To be safe, I seed again right before every random call.”
Then every call starts from the same state and returns the same numbers. A training loop that shuffles its examples each epoch (ML01.5) would see them in one fixed order forever. Seed once, make one generator, and pass it to the functions that need randomness.
“I set np.random.seed(0) at the top, so everything is fixed.”
np.random.seed seeds one hidden global generator that any library may draw from, so a call deep inside a package can move it without your knowing. NumPy recommends making a Generator with default_rng instead, and this course always does: rng = np.random.default_rng(0) at the top of the notebook, passed on by name.
“With the same seed I will get the same numbers on any computer, for ever.”
NumPy promises the same stream only for the same generator, seed and calls, on the same build of NumPy, in the same environment, on the same machine, and it may change streams between feature releases. Write the library versions down beside a seeded result; the next lesson shows how.
Check yourself
Recall
1. What two things fix every number a pseudo-random generator produces?
Show answer
Its rule and its seed (the starting state), together with the order in which the numbers are drawn.
2. What are the first four outputs of \(x \to {(5x + 3)} \bmod 16\) from the seed 7?
Show answer
6, 1, 8 and 11.
3. Why must a generator with \(m\) possible states repeat within \(m\) steps?
Show answer
Among \(x_0, \dots, x_m\) two states are equal, since there are only \(m\) values; equal states have equal successors, so from there the sequence repeats.
4. Why does running one cell twice change the random numbers of later cells?
Show answer
Every draw moves the generator’s state, so the extra run uses up numbers that later cells would have received.
5. How do DataFrame.sample and train_test_split take a seed?
Show answer
Through the argument random_state: an integer gives the same rows on every call.
Exercises
1.★ Run \(x \to {(5x + 3)} \bmod 16\) from the seed 0 for four steps, and turn the outputs into numbers in \([0, 1)\).
Show solution
\({5 \cdot 0} + 3 = 3\), so \(x_1 = 3\). \({5 \cdot 3} + 3 = 18 = 16 + 2\), so \(x_2 = 2\). \({5 \cdot 2} + 3 = 13\), so \(x_3 = 13\). \({5 \cdot 13} + 3 = 68 = {4 \cdot 16} + 4\), so \(x_4 = 4\). Divided by 16: 0.1875, 0.125, 0.8125 and 0.25. These are the states after 0 on the ring of the anchor figure, as lcg(0, 4) confirms.
2.★★ Use the conditions of the deeper block to decide whether \(x \to {(9x + 1)} \bmod 16\) visits all 16 states, then check by listing the states from the seed 0.
Show solution
\(c = 1\) shares no factor with 16; \(a - 1 = 8\) is even; and 8 is divisible by 4. All three conditions hold, so the period is 16. From 0: 1, 10, 11, 4, 5, 14, 15, 8, 9, 2, 3, 12, 13, 6, 7 and then 0, sixteen distinct states. lcg(0, 16, a=9, c=1) gives the same list.
3.★★★ A notebook has rng = np.random.default_rng(0) in cell 1, noise = rng.normal(size=4) in cell 2 and order = rng.permutation(4) in cell 3. Someone reruns cell 3 alone after a clean run. Is order the same as before? Rewrite the cells so that the shuffle cannot depend on how often cell 2 was run.
Show solution
No. The clean run used the state after one normal call; rerunning cell 3 starts from the state after that first permutation, so it draws a new order. One fix gives each task its own generator, made from its own seed in the cell that uses it, for example order = np.random.default_rng(1).permutation(4) in cell 3. Its result now depends only on that line. The general rule stays: test with Restart and Run All.
Summary card
Random numbers are a rule applied to a state; the seed is the first state.
P02.9.2-F1
One rule, one seed, one sequence.
\(x_{k+1} = {(a\,x_k + c)} \bmod m\): same seed, same sequence; period at most \(m\).
rng = np.random.default_rng(0) once, at the top; pass rng on.
Every draw moves the state, so the order of cells matters.
random_state=0 in DataFrame.sample and train_test_split; report results over several seeds.
Sources and next steps
Sources. Each was opened and checked against this lesson on 15 November 2026.
NumPy developers, Random sampling, NumPy 2.5 manual: create a Generator with default_rng and call its methods; the generators are deterministic and reproduced from a seed; default_rng uses PCG64; the functions in numpy.random use a single global RandomState, and moving to Generator is recommended.
NumPy developers, Compatibility policy, NumPy 2.5 manual: the same stream is promised only for the same bit generator, seed and calls, on the same build of NumPy in the same environment on the same machine; streams may change in feature releases.
NumPy developers, PCG64, NumPy 2.5 manual: the state is a 128-bit value advanced by a linear congruential generator, with a fixed odd increment; the variant used is PCG XSL RR 128/64, which hands out 64 bits made from each 128-bit state.
Wikipedia, Linear congruential generator, sections on the definition and Period length (m a power of 2, c ≠ 0): the recurrence, the names modulus, multiplier, increment and seed, and the three conditions of the Hull–Dobell theorem.
pandas developers, DataFrame.sample, pandas 3.1.1: random_state accepts an integer seed or a NumPy Generator.
scikit-learn developers, train_test_split, scikit-learn 1.9.1: pass an integer random_state for output that repeats across calls; with no sizes given, the test set is 0.25 of the data.