Skip to content

DISCUSS: Migration path from legacy RandomState to NumPy Generator #877

Open

Description

@mmcky

The decision needed

Our RNG plumbing is the 2015-era scikit-learn idiom: check_random_state (util/random.py) normalizes to np.random.RandomState, reaches into np.random.mtrand._rand for the global instance, and rng_integers exists solely to bridge RandomState/Generator signature differences. NumPy has recommended Generator/default_rng since 1.17 (2019): the actively developed API, faster bit generators, better streams. We accept Generator today, but every internal default and documented example takes the legacy path.

This is a decision, not a patch, because reproducibility is the product: for integer seeds, default_rng(seed) and RandomState(seed) produce different streams, so a silent default switch changes every seeded simulation result in downstream research and the QuantEcon lectures.

Questions for this thread

  1. Do we migrate defaults at all, or only document Generator as the preferred input?
  2. If we migrate: flag-day at a major-ish version with a loud changelog, or a transition period where integer seeds emit a FutureWarning naming the change date?
  3. What do the lecture repositories need from us (coordination, pinned examples)?
  4. Does check_random_state grow a default="generator" parameter, or do we adopt np.random.default_rng passthrough plus spawn for parallel paths?

Suggested outcome

A short policy (target version, warning strategy, docs plan) recorded here, then this thread closes and spawns the implementation issue. Related: the vendoring of rng_integers (this milestone) is worth doing first regardless; a completed migration eventually deletes the vendored copy too.


From the July 2026 technical-debt audit (AI-assisted; claims verified against 28d4b3b on 2026-07-25).

Activity

  1. mmcky commented on Sep 9, 2026

    @mmcky
    ContributorAuthor

    Some facts to frame this, then a concrete proposal on each of the four questions so the thread has something to agree with or reject rather than a blank page.

    The surface. random_state= appears 182 times across 13 non-test modules — game_theory/ (brd, fictplay, localint, logitdyn, normal_form_game, random, game_generators/bimatrix_generators), markov/ (approximation, core, random), random/utilities.py, and util/ (random, __init__). Against that, the rng= spelling introduced by #917 exists on exactly one public function, random.draw.

    One constraint that shapes everything else. draw(cdf, size, rng=None) resolves None to np.random — the legacy global module — so np.random.seed(1234); draw(cdf, 10) is reproducible today, and that idiom is used throughout the lectures. If None ever resolves to default_rng() instead, the seed silently stops controlling the draw. So None must keep meaning "the legacy global" until a deliberate flag day, independently of what we decide about explicit seeds.

    1. Do we migrate defaults at all? Proposal: no — never change what an existing parameter means. The reproducibility problem exists entirely because random_state=1234 would begin producing a different stream. A new parameter avoids it completely: random_state= keeps its exact current semantics for its whole life, and rng=, accepting None or a Generator, becomes the modern spelling. Users move by editing code, so no published result ever moves under them. #917 already did this for draw; this is just making it the pattern.

    2. Flag-day or FutureWarning? Proposal: neither, because with a new parameter name there is no stream change to warn about. What is needed is an ordinary deprecation of the old parameter: rng= added alongside random_state= with both working silently; random_state= gains a DeprecationWarning only once the lecture repositories have migrated; removal no earlier than v1.0. Note that 182 call sites is a far larger surface than the timer deprecations in #833 — switching the warning on early makes every lecture that seeds a simulation noisy, which is why the warning should follow the lecture migration rather than lead it.

    3. What the lecture repositories need. Proposal: a window in which both spellings work with no warnings, so lectures migrate per-lecture rather than in one sweep; and explicit guidance that a seeded example whose published figures must reproduce should stay on random_state= until that lecture is re-run and its outputs regenerated. Practically, a tracking issue in the lecture repos before the warning is switched on, not after.

    4. Does check_random_state grow a default= parameter? Proposal: no — do not grow it. Its contract is "give me a RandomState", and a mode flag makes every call site ambiguous about which type comes back. draw shows the alternative: accept None | Generator directly and resolve None locally, with no helper at all. If a helper does prove necessary for the rng= path it should be a separate small function with one job. spawn for parallel paths deserves its own issue — it is a capability we lack under either spelling, and bundling it here will stall this decision.

    5. A fifth question this thread should absorb: the shape of rng_integers. #946 vendors SciPy's implementation to remove the private scipy._lib._util import (#868). That implementation hard-codes gen=None to np.random.mtrand._rand, which is precisely the choice question 1 above is about — so adopting it wholesale settles part of this policy by default. There is also a licensing wrinkle: QuantEcon.py is MIT and SciPy is BSD-3, so vendoring carries a licence block, where quantecon/util/compat.py takes a one-line attribution for code from the same SciPy file. Since the dispatch logic is dictated entirely by the two NumPy APIs, writing a small adapter of our own may be cleaner than vendoring, and lets us choose the None semantics deliberately. #946 is held pending this.

    What this adds up to: a long, boring migration with no flag day and no changed streams — add rng= module by module, leave random_state= alone, warn only when the lectures are ready, remove at v1.0 or later. The cost is carrying two parameters for a while. The benefit is that no downstream result changes silently, which given this issue's own framing — "reproducibility is the product" — looks like the right trade.

    If that is broadly agreeable, the implementation issue this thread spawns is "add rng= across the 13 modules", and #946 can proceed once question 5 is answered.

  2. oyamad commented on Sep 10, 2026

    @oyamad
    Member

    My thoughts:

    • draw is an exception; out of the scope here. Its restriction is a Numba constraint, not a design to follow in other functions.
    • The idiom np.random.seed(1234) is a bad convention. I understand such idioms in the lectures are going to be removed.
    • Then just follow "SPEC 7":
      • Add an rng keyword, and internally pass it to numpy.random.default_rng (which also accepts an integer seed).
      • For transition, follow the decorator approach as described in there (where we will need some adjustments).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions