diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4c15c..27c4318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 未安装(本机离线)。 diff --git a/STATUS.md b/STATUS.md index 601be7e..018c001 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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)走完整流程,反向沉淀函数。 diff --git a/src/mcm_toolkit/core/random.py b/src/mcm_toolkit/core/random.py index 7cfb5c3..97a4346 100644 --- a/src/mcm_toolkit/core/random.py +++ b/src/mcm_toolkit/core/random.py @@ -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 diff --git a/tests/test_core.py b/tests/test_core.py index 125bccf..8cf40fd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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")