fix(core): 补齐 get_rng 类型校验,set_seed 覆盖 numpy 旧全局

get_rng 的"共享单例生成器"设计由用户本人提出,本提交只在其上修 bug。

- random_state 判断从 isinstance(x, int) 放宽到 (int, np.integer):
  np.int64 不是 int 的子类,之前会静默退回全局流、种子被无声忽略
- 非预期类型改成抛 TypeError(含 bool / float / str / list),不再静默降级
- get_rng 签名补上 np.integer 与 Generator,与 docstring 的承诺一致
- set_seed 同时调用 np.random.seed:sklearn / statsmodels 走的是 numpy
  旧全局状态,不覆盖它们"提交能重跑"就有洞(带 noqa: NPY002 与理由)
- 测试:TestRandom 由 4 条增到 7 条(np.integer、非法类型、共享生成器推进)
- 新增 docs/dev/personal-pitfalls.md:个人易错点清单,只追加、不通读,
  首批记录本轮 4 个点;docs/README.md 补上 05 与该文件的索引
- STATUS.md:L0 进度说明更新,决策 24 改写(共享单例)、新增决策 25
  (src/ 实现默认由用户本人写)
This commit is contained in:
guzhujushi committed 2026-10-01 19:38:59 +08:00
1 parent 658b266614
commit c93810ffb1
6 files changed
+158 -35

No files matched your search

+44 -20
View File
@@ -4,7 +4,7 @@
* 所有含随机的公开函数签名都带 `random_state=None`,内部第一行取 rng。
* 禁止使用 `np.random.*` 旧式全局接口,也禁止裸用标准库 `random`。
(唯一例外是 :func:`set_seed` 内部的 `np.random.seed`,见该函数说明。)
(唯一例外是 :func:`set_seed` 里的 `np.random.seed`,见该函数说明。)
* 论文附录必须写明 seed —— 提交要能复现。
"""
@@ -14,25 +14,32 @@ import numpy as np
__all__ = ["get_rng", "set_seed"]
# set_seed 记下来的全局种子;None 表示用户没设过。
_GLOBAL_SEED: int | None = None
# 模块级共享生成器:set_seed 换掉它,无参 get_rng 复用它。
_GLOBAL_RNG: np.random.Generator | None = None
def get_rng(random_state: int | np.random.Generator | None = None) -> np.random.Generator:
def get_rng(
random_state: int | np.integer | np.random.Generator | None = None,
) -> np.random.Generator:
"""取一个随机数生成器。
Parameters
----------
random_state:
`int` 时返回固定种子的独立生成器(可复现);
`None` 时跟随 :func:`set_seed` 设过的全局种子,没设过就交给系统熵源;
`int` / `np.integer` 时返回固定种子的独立生成器(可复现);
`None` 时返回共享的全局生成器(由 :func:`set_seed` 设定,**会被推进**);
已经是 :class:`np.random.Generator` 时原样返回(便于函数间串联)。
Raises
------
TypeError
传进来的既不是整数、也不是 Generator、也不是 None。
Notes
-----
同一个全局种子下,每次 `get_rng()` 拿到的都是**同一条**流。这保证了
"脚本重跑一遍结果一样",但也意味着两个都省略 `random_state` 的函数会共用
同一串随机数。想避开,就显式传不同的 `random_state`。
无参调用返回的是**同一个**生成器对象,所以连续两次 `get_rng()` 取到的随机数
不同(流在推进)。这既保证"重跑一遍结果一样",又避免两个都省略 `random_state`
的函数抽到完全相同的数。
Examples
--------
@@ -42,23 +49,40 @@ def get_rng(random_state: int | np.random.Generator | None = None) -> np.random.
"""
if isinstance(random_state, np.random.Generator):
return random_state
if random_state is None:
random_state = _GLOBAL_SEED # 没设过就是 None,交给系统熵源
return np.random.default_rng(random_state)
# bool 是 int 的子类,但 True 被当成种子 1 几乎总是笔误,宁可报错也不静默接受。
if isinstance(random_state, bool):
raise TypeError("random_state 不接受 bool:True 会被当成种子 1,通常是笔误")
# np.int64 这类 numpy 标量**不是** python int 的子类,必须一起判断;
# 只写 int 的话,传进来的 numpy 整数会静默掉进下面的全局分支,种子被无声忽略。
if isinstance(random_state, (int, np.integer)):
return np.random.default_rng(int(random_state))
if random_state is not None:
raise TypeError(
"random_state 只接受 int / np.integer / np.random.Generator / None,"
f"收到的是 {type(random_state).__name__}"
)
global _GLOBAL_RNG
if _GLOBAL_RNG is None:
_GLOBAL_RNG = np.random.default_rng()
return _GLOBAL_RNG
def set_seed(seed: int) -> None:
def set_seed(seed: int | np.integer) -> None:
"""设置全局随机种子。
除了记住种子,还会一并喂给 numpy 的旧全局状态:sklearn / statsmodels
内部仍在用它(`check_random_state(None)` 那条路),只设自己的
`_GLOBAL_SEED` 的话这些库照样不可复现。
除了换掉本包的共享生成器,还会一并设置 numpy 的**旧全局状态**:
sklearn / statsmodels 内部走 `check_random_state(None)` 时用的就是它,
只管自己的生成器的话,这些库照样不可复现。
Examples
--------
>>> set_seed(42)
"""
global _GLOBAL_SEED
_GLOBAL_SEED = int(seed)
# 这里是全项目唯一允许碰旧接口的地方:它是"设种子",不是"取随机数"。
np.random.seed(_GLOBAL_SEED) # noqa: NPY002 - 故意的,见本函数 docstring
global _GLOBAL_RNG
_GLOBAL_RNG = np.random.default_rng(seed)
# 全项目唯一允许碰旧接口的地方:这里是"设种子",不是"取随机数"。
np.random.seed(seed) # noqa: NPY002