Fix get_params() mutating and being non-idempotent for RNG random_state - #12367
Fix get_params() mutating and being non-idempotent for RNG random_state#12367Nityahapani wants to merge 3 commits into
Conversation
XGBModel.get_params() converts a np.random.RandomState or
np.random.Generator random_state into a plain int for the native
learner. It did this by calling .randint() / .integers() directly on
the RNG object -- which both (a) mutates the caller's own RNG object
as a side effect of what should be a pure accessor call, advancing its
internal state, and (b) makes get_params() non-idempotent: calling it
twice on the same fitted estimator returns a *different*
random_state value each time, since each call draws fresh randomness.
This matters because sklearn's own get_params()/clone() contract
assumes get_params() is a pure, idempotent accessor -- GridSearchCV,
cross_val_score, and any code path that clones or introspects the
estimator relies on this. It also silently advances a RandomState/
Generator object that the calling code may still be using elsewhere,
which is a surprising side effect for a getter to have.
Confirmed via sklearn.base.BaseEstimator.get_params(): the returned
dict holds the *same object reference* as self.random_state, not a
copy, so .randint()/.integers() genuinely mutates the user's own RNG.
Fix: derive a stable seed from the RNG's internal state (read-only)
instead of drawing a new random value:
- RandomState: use get_state(legacy=True), which returns numpy's
classic (str, ndarray, int, int, float) tuple for an MT19937
generator -- verified this is numpy's actual runtime default via
help(np.random.RandomState.get_state), even though the .pyi type
stub for the no-arg call declares a dict[str, Any] return type
(a stub/runtime mismatch); the explicit cast() documents this.
- Generator (PCG64): read bit_generator.state['state']['state']
directly, same non-consuming approach.
This is the same fix pattern used by at least one other sklearn-
compatible library (PySR) for the identical bug: 'fix: potential
issue with non-standard random states', switching from a consuming
.randint() call to reading get_state()[1][0].
Verified standalone (import xgboost requires the compiled C++ core,
which isn't buildable in this sandbox):
- RandomState: get_params() logic now returns the identical value
on repeated calls (idempotent)
- RandomState: the object is provably NOT advanced -- drew a value
from a fresh RNG with the same seed before and after running the
logic, got identical results
- Same two checks repeated for np.random.Generator
- Bounds-checked across a range of seeds including edge values
(0, 2**31-1, 2**32-1) to confirm results stay in the intended
[0, int32_max) range
- Plain int random_state values still pass through unchanged
mypy and ruff both pass on the modified file.
|
Maybe it's better to use the integers function in setter instead of getters? |
Fix get_params() mutating and being non-idempotent for RNG random_stateSummary
Why this matterssklearn's own I confirmed via >>> from sklearn.base import BaseEstimator
>>> class Dummy(BaseEstimator):
... def __init__(self, random_state=None):
... self.random_state = random_state
>>> rs = np.random.RandomState(42)
>>> d = Dummy(random_state=rs)
>>> d.get_params()["random_state"] is rs
TrueSo ChangeDerive a stable seed from the RNG's internal state (read-only) instead of
|
Fix get_params() mutating and being non-idempotent for RNG random_state
Summary
XGBModel.get_params()converts anp.random.RandomStateornp.random.Generatorrandom_stateinto a plain int for the nativelearner. It did this by calling
.randint()/.integers()directly onthe RNG object — which:
be a pure accessor call, advancing its internal state
get_params()non-idempotent — calling it twice on the samefitted estimator returns a different
random_statevalue each time,since each call draws fresh randomness
Why this matters
sklearn's own
get_params()/clone()contract assumesget_params()is apure, idempotent accessor —
GridSearchCV,cross_val_score, and any codepath that clones or introspects the estimator relies on this. It also
silently advances a
RandomState/Generatorobject that the calling codemay still be using elsewhere, which is a surprising side effect for a
getter to have.
I confirmed via
sklearn.base.BaseEstimator.get_params()that the returneddict holds the same object reference as
self.random_state, not acopy:
So
.randint()/.integers()genuinely mutates the user's own RNG object,not a throwaway copy.
Change
Derive a stable seed from the RNG's internal state (read-only) instead of
drawing a new random value:
RandomState: useget_state(legacy=True), which returns numpy'sclassic
(str, ndarray, int, int, float)tuple for an MT19937 generator— verified this is numpy's actual runtime default via
help(np.random.RandomState.get_state), even though the.pyitypestub for the no-arg call declares a
dict[str, Any]return type (astub/runtime mismatch). The explicit
cast()documents this.Generator(PCG64): readbit_generator.state["state"]["state"]directly — same non-consuming approach.
This is the same fix pattern used by at least one other sklearn-compatible
library ([PySR](https://huggingface.co/spaces/MilesCranmer/PySR/commit/dca10d6fc6a9f3237095bdea2a363770b1152f01))
for the identical bug — switching from a consuming
.randint()call toreading
get_state()[1][0].Verification
I reproduced the exact logic standalone and verified:
RandomState/Generatorobject now returns the identical value both timesbefore and after running the fixed logic — got identical draws,
confirming the RNG's internal state was not advanced (for both
RandomStateandGenerator)0,2**31-1,2**32-1) to confirm results stay in the intended[0, int32_max)rangerandom_statevalues are unaffectedmypyandruffboth pass on the modified file.