feat(core): 实现 get_rng 与 set_seed

L0 第 2 项落地。

- get_rng(int) 固定种子可复现;get_rng(Generator) 原样透传;
  get_rng() 跟随 set_seed 设过的全局种子,没设过则交给系统熵源
- set_seed(seed) 除记录 _GLOBAL_SEED 外,还调用 np.random.seed,
  让 sklearn / statsmodels 这些走 numpy 旧全局状态的库也能复现;
  这是全项目唯一碰旧接口的位置,标注了 noqa: NPY002 与理由
- 已知取舍写进 docstring:同一全局种子下多次 get_rng() 是同一条流
- tests/test_core.py:TestRandom 去掉 skip;把原来那条自证式的
  test_set_seed_makes_global_reproducible 换成两条真断言
  (不传 random_state 也要可复现、旧全局状态被覆盖)
- STATUS.md:L0 进度 2/12,新增决策 23(允许 set_seed 碰旧接口)
  与 24(不做 SeedSequence.spawn 拆分);CHANGELOG.md 同步
This commit is contained in:
guzhujushi committed 2026-10-01 19:19:15 +08:00
1 parent cb96c847dd
commit 658b266614
4 files changed
+62 -20

No files matched your search

+4 -1
View File
@@ -21,11 +21,14 @@
- `pyproject.toml`:核心依赖只 4 个,其余走 extras
### Changed
- `core.get_rng` / `core.set_seed`:实现(L0 第 2 项)。`get_rng` 支持 int 种子 /
传 Generator 透传 / 无参跟随全局种子;`set_seed` 同时覆盖 numpy 旧全局状态,
让 sklearn / statsmodels 也可复现。
- `core.Result`:补上 `__repr__` 与 `summary()` 实现(L0 第 1 项落地)。
`repr` 紧凑单行且不打印 `data` 内容;`summary()` 输出可直接贴进论文的 Markdown 表格。
### Notes
- 除 `core.Result` 外,其余函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。
- 除 `core.Result`、`core.get_rng`、`core.set_seed` 外,其余函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。
这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。
- 已知缺口:`SALib`(Sobol/Morris)、`pymoo`、`pyright` 未安装(本机离线)。
+17 -7
View File
@@ -58,7 +58,7 @@
- [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`),
`~/.ssh/config` 加了一条域名别名,remote 里仍写域名
**E. L0 实现进度(1/12)**
**E. L0 实现进度(2/12)**
- [x] `core/result.py` 的 `Result`
- `__repr__`:单行紧凑;`data` 只给类型与规模,**绝不**整数组打印
(十万级数组的 repr 实测仍 < 200 字符);`success` 恒在
@@ -67,7 +67,15 @@
float 走 `.6g`,`|` 转义,`message` 以引用行附在表后;幂等无副作用
- 测试:`tests/test_core.py` 去掉 skip + 新增 3 条
(默认值/失败路径/大数组、Markdown 结构、可重复调用)
- [ ] 剩余 11 项(13 个函数):`get_rng` `set_seed` `load_table` `clean_missing`
- [x] `core/random.py` 的 `get_rng` / `set_seed`
- `get_rng(42)` 固定种子可复现;`get_rng(gen)` 原样返回;`get_rng()` 跟随全局种子
- `set_seed(seed)` 除了记住种子,还一并调 `np.random.seed`,
让 sklearn / statsmodels 那些走旧全局的库也能复现(全项目**唯一**碰旧接口的地方,
带 `# noqa: NPY002` 与理由)
- 已知取舍:同一个全局种子下每次 `get_rng()` 拿到**同一条流**(见决策 24)
- 测试:`TestRandom` 去掉 skip,并把原来那条"自己测自己"的空转测试换成
两条真断言(无参可复现、旧全局被覆盖)
- [ ] 剩余 10 项(11 个函数):`load_table` `clean_missing`
`detect_outliers` `normalize` `standardize` `describe` `correlation_matrix`
`bootstrap_ci` `monte_carlo` `save_figure`
@@ -75,15 +83,15 @@
| 检查 | venv | conda `mcm-toolkit` |
|---|---|---|
| `pytest` | 24 passed, 48 skipped | 24 passed, 48 skipped |
| `pytest` | 28 passed, 45 skipped | 28 passed, 45 skipped |
| `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
| `ruff check` | All checks passed | All checks passed |
| `scripts/doctor.ps1` | 跑通 | — |
| SALib / pymoo / pyright | 缺失 | 已装 |
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
- skip 的都是等实现的算法行为测试;通过的 24 条 = 20 条公开 API 契约测试
(`tests/test_api_surface.py`)+ 4 条 `Result` 行为测试(`tests/test_core.py`)
- skip 的都是等实现的算法行为测试;通过的 28 条 = 20 条公开 API 契约测试
(`tests/test_api_surface.py`)+ 8 条 `core` 行为测试(`tests/test_core.py`)
- 三个 `.ps1` 通过 PowerShell AST 语法解析
### ⚠️ 需要注意 / 遗留
@@ -93,7 +101,7 @@
只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。
- [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。
note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。
- [ ] L0 完成 1/12(`Result`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进;
- [ ] L0 完成 2/12(`Result`、`get_rng`/`set_seed`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进;
详情见上面 E 节
- [ ] 本机 `exec_command` 在沙箱模式下**完全不可用**
(`helper_unknown_error: setup refresh had errors`),只有提权
@@ -131,11 +139,13 @@
| 20 | 首次推送用 `--force-with-lease` **覆盖** Gitea 的 `Initial commit` | 远端那个提交是建仓时自动初始化产生的,与本地无共同祖先,普通 push 必被拒。推送前已把其中的 LICENSE 与手写简介并入本地 `README.md`,原提交仍留在本机对象库(`1c683cf`) |
| 21 | `Result.__repr__` **不打印 `data` 内容**,只给类型与规模 | 模拟轨迹动辄十万个数,repr 一旦刷屏,traceback / 调试器 / notebook 输出就全废了。要看数据请直接取 `result.data` |
| 22 | `Result.summary()` 的行序 = 字典插入顺序,不排序 | 比赛时"想先看哪个指标"自己决定键的插入顺序即可,工具不该替人排序 |
| 23 | `set_seed` 内部**允许**调用 `np.random.seed`(带 noqa) | sklearn / statsmodels 默认走 numpy 旧全局状态,只记自己的 `_GLOBAL_SEED` 覆盖不到它们,"提交能重跑"就是假的。旧接口在这一个点上不是"取随机数"而是"设种子",性质不同 |
| 24 | `get_rng(None)` 不做 `SeedSequence.spawn` 拆分,同一种子下多条流相同 | 多引入一个模块级计数器,会让"同一次调用顺序不同 → 结果不同",对刚上手的人更难解释。取舍写进 docstring,真要独立流就显式传不同 `random_state` |
## 下一步
1. ~~接入 Gitea~~ **已完成**。以后推送前先确认 Tailscale 在线,然后 `git push`
(细节与换机步骤见 `docs/05-git-workflow.md` §3)。
2. 继续 L0,**下一个:`core/random.py` 的 `get_rng` / `set_seed`**(先写测试再写代码)。
2. 继续 L0,**下一个:`data/io.py` 的 `load_table`**(`save_table` 属 L1,顺手也可做)。
3. 每写完一个函数,在 `examples/` 里加一行最小用法,并更新本文件。
4. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
+25 -6
View File
@@ -4,6 +4,7 @@
* 所有含随机的公开函数签名都带 `random_state=None`,内部第一行取 rng。
* 禁止使用 `np.random.*` 旧式全局接口,也禁止裸用标准库 `random`。
(唯一例外是 :func:`set_seed` 内部的 `np.random.seed`,见该函数说明。)
* 论文附录必须写明 seed —— 提交要能复现。
"""
@@ -13,33 +14,51 @@ import numpy as np
__all__ = ["get_rng", "set_seed"]
# set_seed 记下来的全局种子;None 表示用户没设过。
_GLOBAL_SEED: int | None = None
def get_rng(random_state: int | None = None) -> np.random.Generator:
def get_rng(random_state: int | np.random.Generator | None = None) -> np.random.Generator:
"""取一个随机数生成器。
Parameters
----------
random_state:
`int` 时返回固定种子的独立生成器(可复现);
`None` 时返回基于全局种子的生成器;
`None` 时跟随 :func:`set_seed` 设过的全局种子,没设过就交给系统熵源;
已经是 :class:`np.random.Generator` 时原样返回(便于函数间串联)。
Notes
-----
同一个全局种子下,每次 `get_rng()` 拿到的都是**同一条**流。这保证了
"脚本重跑一遍结果一样",但也意味着两个都省略 `random_state` 的函数会共用
同一串随机数。想避开,就显式传不同的 `random_state`。
Examples
--------
>>> rng = get_rng(42)
>>> bool(rng.random() == get_rng(42).random())
True
"""
# TODO(L0)
raise NotImplementedError
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)
def set_seed(seed: int) -> None:
"""设置全局随机种子。
除了记住种子,还会一并喂给 numpy 的旧全局状态:sklearn / statsmodels
内部仍在用它(`check_random_state(None)` 那条路),只设自己的
`_GLOBAL_SEED` 的话这些库照样不可复现。
Examples
--------
>>> set_seed(42)
"""
# TODO(L0)
raise NotImplementedError
global _GLOBAL_SEED
_GLOBAL_SEED = int(seed)
# 这里是全项目唯一允许碰旧接口的地方:它是"设种子",不是"取随机数"。
np.random.seed(_GLOBAL_SEED) # noqa: NPY002 - 故意的,见本函数 docstring
+16 -6
View File
@@ -1,6 +1,6 @@
"""core 包测试。
TODO(L0):TestRandom / TestValidate 实现后去掉 skip 标记(Result 已完成)。
TODO(L0):TestValidate 实现后去掉 skip 标记(Result 与 Random 已完成)。
"""
from __future__ import annotations
@@ -9,7 +9,6 @@ import numpy as np
import pytest
@pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0")
class TestRandom:
def test_get_rng_is_reproducible(self):
from mcm_toolkit.core import get_rng
@@ -22,14 +21,25 @@ class TestRandom:
g = np.random.default_rng(0)
assert get_rng(g) is g
def test_set_seed_makes_global_reproducible(self):
def test_get_rng_default_follows_global_seed(self):
"""set_seed 之后,不传 random_state 也必须复现——这是"论文能重跑"的底线。"""
from mcm_toolkit.core import get_rng, set_seed
set_seed(7)
first = get_rng().random()
set_seed(7)
second = get_rng().random()
assert first == second
def test_set_seed_also_covers_third_party_global_state(self):
"""sklearn / statsmodels 走的是 np.random 旧全局,set_seed 要连它一起覆盖。"""
from mcm_toolkit.core import set_seed
set_seed(7)
a = np.random.default_rng(7).random()
first = np.random.random() # noqa: NPY002 - 这里就是要验证旧全局被设上了
set_seed(7)
b = np.random.default_rng(7).random()
assert a == b
second = np.random.random() # noqa: NPY002
assert first == second
@pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0")