Compare commits
1
Commits
main
..
1c683cfb4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c683cfb4d |
No files matched your search
@@ -1,30 +0,0 @@
|
|||||||
# 统一编辑器行为。多人(三人都用同一仓库)时能省掉大量格式争吵。
|
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[*.py]
|
|
||||||
max_line_length = 100
|
|
||||||
|
|
||||||
[*.{yml,yaml,json,toml}]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
|
|
||||||
# PowerShell 脚本:UTF-8 无 BOM + LF(见 AGENTS.md)。
|
|
||||||
# 且必须是纯 ASCII —— Windows PowerShell 5.1 在无 BOM 时按 ANSI 解析 .ps1,
|
|
||||||
# 非 ASCII(例如中文)会变成非法字节并直接导致语法错误。
|
|
||||||
[*.ps1]
|
|
||||||
end_of_line = lf
|
|
||||||
charset = utf-8
|
|
||||||
|
|
||||||
[*.{bat,cmd}]
|
|
||||||
end_of_line = crlf
|
|
||||||
charset = utf-8
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 行尾策略:仓库内统一 LF,checkout 不做自动转换。
|
|
||||||
#
|
|
||||||
# 理由见 AGENTS.md:shell 脚本必须 UTF-8 无 BOM + LF。
|
|
||||||
# 这个项目还会被队友在 Windows / WSL / Linux 上 clone,
|
|
||||||
# 不固定行尾的话,每次提交都会出现"整文件都变了"的假 diff。
|
|
||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
# ---- 文本,显式声明 ----
|
|
||||||
*.py text eol=lf
|
|
||||||
*.md text eol=lf
|
|
||||||
*.toml text eol=lf
|
|
||||||
*.yml text eol=lf
|
|
||||||
*.yaml text eol=lf
|
|
||||||
*.json text eol=lf
|
|
||||||
*.cfg text eol=lf
|
|
||||||
*.txt text eol=lf
|
|
||||||
*.tex text eol=lf
|
|
||||||
*.bib text eol=lf
|
|
||||||
.gitignore text eol=lf
|
|
||||||
.gitattributes text eol=lf
|
|
||||||
.editorconfig text eol=lf
|
|
||||||
|
|
||||||
# PowerShell:必须是纯 ASCII + LF(Windows PowerShell 5.1 无 BOM 时按 ANSI 解析,
|
|
||||||
# 中文会直接导致语法错误)。见 docs/04-environment.md §8。
|
|
||||||
*.ps1 text eol=lf
|
|
||||||
|
|
||||||
# 批处理必须 CRLF,否则 cmd 解析会出问题
|
|
||||||
*.bat text eol=crlf
|
|
||||||
*.cmd text eol=crlf
|
|
||||||
|
|
||||||
# ---- 二进制,禁止任何转换 ----
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
*.gif binary
|
|
||||||
*.pdf binary
|
|
||||||
*.eps binary
|
|
||||||
*.svg text
|
|
||||||
*.xlsx binary
|
|
||||||
*.xls binary
|
|
||||||
*.docx binary
|
|
||||||
*.pptx binary
|
|
||||||
*.zip binary
|
|
||||||
*.exe binary
|
|
||||||
*.dll binary
|
|
||||||
*.db binary
|
|
||||||
*.ico binary
|
|
||||||
|
|
||||||
# ---- diff 行为 ----
|
|
||||||
# 笔记本是 JSON 文本,可以正常 diff,但不要参与语言统计
|
|
||||||
*.ipynb -linguist-detectable
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
# ---- Python ----
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.egg-info/
|
|
||||||
*.egg
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
.eggs/
|
|
||||||
|
|
||||||
# ---- 虚拟环境 ----
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
env/
|
|
||||||
.conda/
|
|
||||||
*.conda-env/
|
|
||||||
|
|
||||||
# ---- 测试 / 类型检查 ----
|
|
||||||
.pytest_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
.mypy_cache/
|
|
||||||
.pyright/
|
|
||||||
.coverage
|
|
||||||
htmlcov/
|
|
||||||
|
|
||||||
# ---- Jupyter ----
|
|
||||||
.ipynb_checkpoints/
|
|
||||||
*.ipynb.bak
|
|
||||||
|
|
||||||
# ---- IDE ----
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
|
|
||||||
# ---- 论文产出(本地生成,不入库)----
|
|
||||||
# 图表的中间产物不入库;定稿的成品图请放到 problems/<年份>_<题号>/figures/ 再提交
|
|
||||||
figures/*.png
|
|
||||||
figures/*.pdf
|
|
||||||
!figures/.gitkeep
|
|
||||||
tables/*.csv
|
|
||||||
!tables/.gitkeep
|
|
||||||
|
|
||||||
# ---- 真题原始数据 ----
|
|
||||||
# 赛题原始数据文件(体积大、可重新下载)不入库,只提交你写的处理脚本和结论
|
|
||||||
problems/**/raw/
|
|
||||||
problems/**/*.xlsx
|
|
||||||
problems/**/*.csv
|
|
||||||
!problems/**/README.md
|
|
||||||
|
|
||||||
# ---- 临时文件 ----
|
|
||||||
tmp/
|
|
||||||
*.log
|
|
||||||
*.tmp
|
|
||||||
.probe.tmp
|
|
||||||
|
|
||||||
# ---- 系统 ----
|
|
||||||
Thumbs.db
|
|
||||||
desktop.ini
|
|
||||||
$RECYCLE.BIN/
|
|
||||||
|
|
||||||
# 受限/沙箱环境里 PowerShell 会以当前工作目录为基准生成字面量 "%SystemDrive%"
|
|
||||||
# 目录(实际内容是 %SystemDrive%\ProgramData\Microsoft\Windows\Caches,
|
|
||||||
# 即 COM 注册表缓存)。正常终端不会出现,删掉也会再生成,所以直接忽略。
|
|
||||||
%SystemDrive%/
|
|
||||||
-682
@@ -1,682 +0,0 @@
|
|||||||
# 更新日志
|
|
||||||
|
|
||||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
|
||||||
版本号参考 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `viz.save_figure`:L0 最后一项落地(Codex 写)。入口校验先于副作用,支持 `png` / `pdf` / `svg` / `eps` / `ps` / `jpg` / `jpeg` / `tif` / `tiff` / `webp`;`path` 带图片后缀时自动去后缀。`tests/test_viz.py` 去掉模块级 skip 并新增 8 条保存测试。
|
|
||||||
- 仓库骨架:`src/mcm_toolkit/` 7 个包(core / data / stats / models / solve / decision / viz)
|
|
||||||
- 顶层 `__init__.py` 只导出 7 个子包 + 8 个快捷函数,`__all__` 即公开 API 清单
|
|
||||||
- `core.Result`:唯一的结果对象(替代原建议里的 5 个 Result 类)
|
|
||||||
- `docs/01-suggestion-review.md`:对 ChatGPT 建库建议的审查与精简(14 包 → 7 包,~250 函数 → ~40)
|
|
||||||
- `docs/02-architecture.md`:包边界、`Result` / RNG / `fit-predict-score` 协议、依赖分层
|
|
||||||
- `docs/03-roadmap.md`:L0/L1/L2 分级 + **永久不做清单**
|
|
||||||
- `docs/04-environment.md`:conda / venv 配置与三个已踩的坑
|
|
||||||
- `docs/dev/function-card-template.md`:S 档算法的函数卡片模板
|
|
||||||
- `.venv`(基于 Anaconda base,`--system-site-packages`)+ 可编辑安装 —— 10.5 MB,离线可用
|
|
||||||
- conda 环境 `mcm-toolkit`(`D:\anaconda\envs\mcm-toolkit`)—— Python 3.13.15,含 SALib / pymoo / pyright
|
|
||||||
- `scripts/setup-conda.ps1` / `scripts/setup-venv.ps1` / `scripts/doctor.ps1`
|
|
||||||
- `environment.yml`:需联网时的精确复现方案
|
|
||||||
- `pyproject.toml`:核心依赖只 4 个,其余走 extras
|
|
||||||
- `docs/02-architecture.md` §4.7「类型约定」:注解要写全、numpy 标量的判断写法、
|
|
||||||
非法类型必须抛 `TypeError`。配套在 `docs/03-roadmap.md` 的开发纪律里加了指针。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:个人易错点清单,**只追加、不通读**
|
|
||||||
- `data.clean`:四个函数落地(**由用户本人实现**)——`clean_missing`(L0 第 4 项)、
|
|
||||||
`impute_missing` / `detect_outliers` / `winsorize`(原属 L1,顺手写了)。
|
|
||||||
- `tests/test_data.py`:新增 13 条 clean 行为测试,覆盖 `clean_missing` 的阈值语义与轴向、
|
|
||||||
`impute_missing` 的 mean/ffill/mode 与全 NaN 列、`winsorize` 的分位点与异常类型。
|
|
||||||
- `tests/test_data.py`:另加 7 条 `xfail`,把**已知但未修的契约**钉在测试报告里(见 Notes)。
|
|
||||||
- `docs/algorithms/detect_outliers.md`:**第一张 S 档函数卡片**(roadmap §3.1 要求
|
|
||||||
约 15 张,这是第 1 张)。除模板九节外,额外记了三条容易踩的判断:为什么不返回 `Result`、
|
|
||||||
非数值列恒 `False` 的两条备选路各错在哪、以及 `ddof` 口径与 `stats.zscore` 不一致。
|
|
||||||
- `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` /
|
|
||||||
`impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测跑通(exit 0);
|
|
||||||
末尾留 `TODO(L0)` 等 `stats.describe`。
|
|
||||||
- `tests/test_data.py`:`data` 的 transform 段 **18 条用例** —— `normalize` 的三种 method、
|
|
||||||
`feature_range` 区间与两条校验、常量列不出 nan、docstring 那个 Examples 的精确值、按列语义;
|
|
||||||
`standardize` 的按列与 `ddof`(用解析关系钉住:`ddof=1` 时结果的 std = `sqrt((n-1)/n)`);
|
|
||||||
`log_transform` 的定义域平移;`one_hot_encode` 的自动识别、`drop_first`、列不存在报错。
|
|
||||||
其中两条是把原先挂着的 `skip` 转为实跑(见坑 9 / 个人易错点 9)。
|
|
||||||
|
|
||||||
- `tests/test_stats.py`:新写 **20 条**用例 —— `describe` 的方向约定(行 = 指标、列 = 变量)、
|
|
||||||
`missing` / `missing_ratio` / `skewness` / `kurtosis` 四行**不能是 NaN**、非数值列自动跳过、
|
|
||||||
接受 ndarray / list / Series、加权均值与加权标准差对照手算公式、5 种非法 `weights`;
|
|
||||||
`zscore` 的手算对照 / 按列 / 常量列 / 非法类型;`coefficient_of_variation` 的手算对照、
|
|
||||||
单列 DataFrame、均值为 0 抛 `ValueError`、空数组与二维输入。
|
|
||||||
- `examples/01_clean_and_describe.py`:填掉末尾的 `TODO(L0)`,加一段 `describe` 输出(实测 exit 0)。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `stats.correlation_pvalues` / `stats.correlation_table`:`correlation_matrix` 的伴随函数
|
|
||||||
(**方案 A**,用户 2026-10-03 拍板,见决策 41)——
|
|
||||||
`correlation_matrix` 只返回**数值**矩阵;`correlation_pvalues` 给数值 p 值矩阵、
|
|
||||||
`correlation_table` 给"系数 + 显著性星标"的论文表格。
|
|
||||||
三者共用 `_check_corr_args` 入参守卫与**成对删缺失**口径,`correlation_table` 在最外层调另外两个。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `core.get_rng` / `core.set_seed`:实现(L0 第 2 项)。`get_rng` 收 `int` / `np.integer` /
|
|
||||||
`Generator` / `None`,其余类型抛 `TypeError`;无参调用复用同一个会推进的共享生成器。
|
|
||||||
`set_seed` 同时覆盖 numpy 旧全局状态,让 sklearn / statsmodels 也可复现。
|
|
||||||
- `core.Result`:补上 `__repr__` 与 `summary()` 实现(L0 第 1 项落地)。
|
|
||||||
`repr` 紧凑单行且不打印 `data` 内容;`summary()` 输出可直接贴进论文的 Markdown 表格。
|
|
||||||
- `data.load_table`:实现(L0 第 3 项)。按扩展名分派 `.csv` / `.xlsx` / `.xls`;
|
|
||||||
`.csv` 默认做编码探测(utf-8 → utf-8-sig → gbk → gb18030),显式传 `encoding=` 时不再探测;
|
|
||||||
`na_values` 默认吃下空串 / `NA` / `N/A` / `#N/A` / `null` / `-`。
|
|
||||||
- `data.save_table`:实现(原属 L1,顺手落地)。按扩展名分派,父目录不存在时自动创建,
|
|
||||||
返回实际写入的 `Path`。
|
|
||||||
- `data.load_table` / `data.save_table`:docstring 补 `Parameters` / `Raises`
|
|
||||||
(`docs/02-architecture.md` §4.7 对每个公开函数的硬要求)。
|
|
||||||
- `data.load_table` 的返回注解 `Any` → `pd.DataFrame`,与 docstring 的承诺对齐。
|
|
||||||
- `data.clean`:docstring 按 `docs/02-architecture.md` §4.7 补全 Parameters / Returns / Raises
|
|
||||||
(Codex 代写,与 io.py 同一套 numpydoc 格式)。
|
|
||||||
- `data.clean`:ruff 从 6 条清到 1 条——导入分组(I001)、elif 链改 if 加末尾无条件 return
|
|
||||||
(RET505 / RET503)、去掉多余 f 前缀(F541)、删掉只会被覆盖的 mask 初始化(死代码)、
|
|
||||||
去掉行尾空白(W293)。
|
|
||||||
- `pyright` 报"无法解析导入 numpy / pandas"(17 errors)的原因:分析器会去 PATH 上找 python,
|
|
||||||
本机 PATH 上只有 Codex 运行时的 3.14 shim(无科学栈),且 pyright 不认
|
|
||||||
`pyvenv.cfg` 的 `include-system-site-packages`。
|
|
||||||
改为 `[tool.pyright]` 指定 `venvPath = "."` / `venv = ".venv"` /
|
|
||||||
`pythonVersion = "3.13"`,并让 `scripts/setup-venv.ps1` 往 `.venv` 里写一个
|
|
||||||
指向 base site-packages 的 `.pth`。pyright 17 errors → **0**。
|
|
||||||
详见 `docs/04-environment.md` 坑 7。
|
|
||||||
- `tests/test_data.py`:`df["b"].isna().all()` 的类型是 `Series | bool`,
|
|
||||||
直接当断言条件 pyright 会报"条件操作数无效",外面套 `bool()`;
|
|
||||||
两条故意传错类型的用例加 `# type: ignore[arg-type]`。
|
|
||||||
- `data.save_table` 的后缀校验排在 `path.parent.mkdir(...)` **之后**,传 `.txt` 会先留下一个
|
|
||||||
空目录再抛 `ValueError`。改为**先校验、后副作用**,并加回归测试。
|
|
||||||
- `data/io.py` 里 `__all__` 与下一个 `def` 之间只有 1 个空行(PEP 8 要 2 个)。
|
|
||||||
- `tests/test_data.py` 顶上的**模块级** `pytestmark = pytest.mark.skip(...)` 会连带跳过同文件里
|
|
||||||
已经写好的函数,导致 `load_table` 零覆盖。改为逐条 skip 未实现的算法,并补 8 条 io 测试
|
|
||||||
(编码探测、NA 归一、后缀校验、缺文件报错、往返读写、类型校验)。测试数 31 → **39 passed**。
|
|
||||||
- conda base 里 zstandard 的 `*.conda_trash` 残骸目录被 Python 当成 namespace package 导入,
|
|
||||||
使 `.venv`(= base)的 `pd.read_csv` 抛 `ImportError: Can't determine version for zstandard`。
|
|
||||||
已把残骸目录改名禁用(**可逆**,未删除),详见 `docs/04-environment.md` 坑 6。
|
|
||||||
- `data.clean` 第二轮审查(2026-10-02),Codex 直接修掉 4 处**轻度**问题:
|
|
||||||
`winsorize(Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩(Series 分支提到最前,
|
|
||||||
并加数值 dtype 守卫);`winsorize` 的 `data.quantile()` 改为只在数值列上算
|
|
||||||
(文本列不再崩,与已有的 `numeric_cols` 判断对齐);删掉 `detect_outliers` 里已成空操作的
|
|
||||||
`df = pd.DataFrame(data)`(数据在更早处就转过了,顺手去掉 `df` 这个别名);
|
|
||||||
清掉行尾空白(W293)、函数体内的连续空行,并把 `clean_missing` 的嵌套 `if` 合并
|
|
||||||
(SIM102,ruff 最后 1 条)。ruff `1 error` → **`All checks passed`**,pyright 仍 0。
|
|
||||||
- `tests/test_data.py`:5 条 `xfail` 经 XPASS 信号确认已修好,**摘掉 mark 转正式用例**
|
|
||||||
(`detect_outliers` 的 4 条 + `winsorize` 混合类型 1 条)。测试 **53 passed / 7 xfailed →
|
|
||||||
58 passed / 2 xfailed**。
|
|
||||||
- `CHANGELOG.md`:[Unreleased] 里有一句被重复追加撑坏
|
|
||||||
("…删掉它即全绿。:分析器会去 PATH 上找 python,"),已修。
|
|
||||||
- `data.detect_outliers` 收尾(2026-10-02,**由用户本人实现**):统计量(`quantile` /
|
|
||||||
`mean` / `std`)与掩码写入**都**收窄到数值列,含文本列的赛题表不再崩;
|
|
||||||
非数值列在掩码里恒 `False`(用户本人定的语义,见决策 32)。docstring `Notes` 补上这条语义。
|
|
||||||
- `data.detect_outliers` 里 `data=data.copy()` 是死代码:下面的 `to_frame()` / `pd.DataFrame()`
|
|
||||||
本来就返回新对象,函数全程也没原地改过 `data`,白白复制一份数据;而且它排在类型分派
|
|
||||||
**之前**,会把错误入参的报错从 `ValueError` 变成 `AttributeError`。已删。
|
|
||||||
- `data.detect_outliers`:删掉那行死代码后 pyright 反而报了 1 个真错 ——
|
|
||||||
`df[Index]` 被类型存根当成 `Series`,`.std()` 于是被判成标量、`std.replace(...)` 非法。
|
|
||||||
在取列子表处用 `cast(pd.DataFrame, ...)` 按运行时真值收窄。详见 `docs/04-environment.md` 坑 8。
|
|
||||||
- `tests/test_data.py`:`test_detect_outliers_survives_text_columns` 摘掉 xfail 并加强断言
|
|
||||||
(文本列必须恒 `False`);新增 `test_detect_outliers_all_text_frame_returns_all_false`
|
|
||||||
钉住"整表没有数值列"这条边界。测试 **58 passed / 2 xfailed → 60 passed / 1 xfailed**。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:追加第 8 条(限制掩码 ≠ 限制统计量)。
|
|
||||||
- `data.impute_missing` 的 mode 分支改为**返回新表**(原先 `data[col] = ...` 会就地改调用方的
|
|
||||||
DataFrame,另外四支都返回新对象)。最后一条 `xfail` 随之转正,**测试里 `xfail` 清零**。
|
|
||||||
- `data.detect_outliers` 补**入参类型校验**(§4.7:非法类型抛 `TypeError`,不静默兜底):
|
|
||||||
接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`。`tuple` 同时补进 `is_ld_array`,
|
|
||||||
与 `list` 同等对待;`dict` 不再被接受(以前靠 `pd.DataFrame()` 碰巧能过,见决策 33)。
|
|
||||||
- `tests/test_data.py`:摘掉最后一条 `xfail`(mode 就地改)并加强断言;新增 3 条
|
|
||||||
(非法类型抛 `TypeError`、元组按一维数组型处理、全 NaN 列与单元素列不抛异常)。
|
|
||||||
测试 **60 passed / 1 xfailed → 64 passed / 0 xfailed**。
|
|
||||||
- `data.transform` 四个函数实现(**由用户本人写**):`normalize`(minmax / vector / max,均按列)、
|
|
||||||
`standardize`(按列,`ddof` 真的传下去)、`log_transform`(`offset=None` 时自动取 `1 - min(x)`)、
|
|
||||||
`one_hot_encode`。`data/` 下的 L0 至此结清。
|
|
||||||
- `data.transform`:Codex 清掉 5 条 ruff —— RET503(`normalize` 末尾缺无条件 `return`)、
|
|
||||||
F541(无占位符的 f-string)、RET504(多余的 `result` 中转)、RET505(`return` 后的 `elif`)、
|
|
||||||
SIM108(`log_transform` 的 if/else 改三元)。
|
|
||||||
- `data.transform.normalize`:`supported_method` 与 `feature_range` 的校验提到 `np.asarray` **之前**
|
|
||||||
(§4.7 的"守卫集中在开头");并修掉说反的报错文案 —— 条件是要求下界 < 上界,
|
|
||||||
原文却写"上界必须小于下界",且 `TypeError` 那条用了无占位符 f-string。
|
|
||||||
- `data.transform.one_hot_encode`:自动识别类别列时显式加上 `'str'` / `'string'`。
|
|
||||||
原来只写 `['object', 'category']`,在 pandas 3 上是**已弃用路径**(每次调用发 `Pandas4Warning`),
|
|
||||||
未来会变成一列都选不中的**静默错误**。详见坑 9。
|
|
||||||
- 测试计数:**64 passed → 82 passed**(新增 18 条,另两条 skip 转为实跑),skip 42 → 40。
|
|
||||||
|
|
||||||
|
|
||||||
- `data.transform`:修掉上一版引入的 **3 处真 bug**(**Codex 代修,用户授权"报错顺手修掉"**)。
|
|
||||||
症状是 7 条测试变红 + pyright 在 `transform.py:105` / `:133` 报 `reportArgumentType`。
|
|
||||||
**这不是编辑器误报** —— pyright CLI 与 `pytest` 都能复现,所以**一行 `# type: ignore` 都没加**:
|
|
||||||
- 输入白名单漏了 `pd.DataFrame`:`standardize(df)` / `log_transform(df)` 抛 `TypeError`,
|
|
||||||
函数体里的 `is_df` 分支全是**死代码**。白名单统一成模块级 `_NUMERIC_TYPES` 并移到 `np.asarray` 之前。
|
|
||||||
- `normalize` 的三个 method 分支**互相覆盖**:`minmax` / `vector` 算完的结果被末尾无条件的
|
|
||||||
`x / max` 冲掉,三种 method 实际都只走 max(`test_normalize_*` 5 条因此变红)。改成 `if / elif / else`。
|
|
||||||
- `one_hot_encode` 把 `get_dummies` 的结果按 `data.columns` 重包一层 `DataFrame`:
|
|
||||||
哑变量列名(`city_A`)与原列名(`city`)对不上 → 编码列被**整列丢掉**。改为直接返回 `get_dummies` 结果。
|
|
||||||
- `log_transform` 的 `offset` 守卫条件**写反**(`offset > 0` 就抛错,恰好把唯一合法用例拒掉),
|
|
||||||
改为校验定义域前提 `min(x) + offset > 0`;`offset=0` 对全正数据合法(见决策 35)。
|
|
||||||
- `data.transform` 四个函数的 docstring 按 `docs/02-architecture.md` §4.7 补齐
|
|
||||||
`Parameters` / `Returns` / `Raises`(Codex 代写,与 `io.py` / `clean.py` 同一套 numpydoc 格式),
|
|
||||||
并把常量列行为(决策 34)写进 `Notes`。
|
|
||||||
- `data.transform`:口径定稿后 ruff 剩的 4 条 RET505(`return` 后的 `elif`)一并清掉 ——
|
|
||||||
`All checks passed`;pyright **2 errors → 0 errors**;测试 **7 failed / 75 passed → 82 passed / 40 skipped**。
|
|
||||||
|
|
||||||
- `stats.describe`:实现落地(**由用户本人写**,Codex 审查 + 代修),并修掉一个**静默 bug** ——
|
|
||||||
`df_num.describe().T` 把表转反了(行 = 变量、列 = 指标),而 docstring 与
|
|
||||||
`test_describe_reports_expected_keys` 定的是"行 = 指标、列 = 变量"。
|
|
||||||
转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名**零交集**,pandas 不报错,
|
|
||||||
直接把这四行(`missing` / `missing_ratio` / `skewness` / `kurtosis`)**整行填成 NaN**;
|
|
||||||
`weights` 分支则报 `cannot set a row with mismatched columns`。去掉 `.T` 一处修好三件事。
|
|
||||||
另补 `median` 行(pandas 只有 `50%`,测试契约定的是 `median`)、入参放宽到
|
|
||||||
`list` / `tuple` / `ndarray`(`00_quickstart.py` 传的就是 ndarray)、
|
|
||||||
`weights` 校验补齐(维度 / 长度 / NaN / 负值 / 和为 0,原先和为 0 时裸抛 `ZeroDivisionError`)、
|
|
||||||
docstring 按 §4.7 补 `Parameters` / `Returns` / `Raises`。
|
|
||||||
- `stats.zscore`:修 RET505;docstring 按 §4.7 补齐;DataFrame 取列处按坑 8 用 `cast` 收窄
|
|
||||||
(pyright 的 `DataFrame | Series` 重载)。
|
|
||||||
- `stats.coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError`
|
|
||||||
(那是**取值**问题不是类型问题,§4.7);空数组提前抛 `ValueError`(原先静默返回 nan + RuntimeWarning);
|
|
||||||
报错文案里"异变系数"的错别字与 "N 维数组" 改为 "N 列"。
|
|
||||||
- `stats.coefficient_of_variation`:分母由 `mean` 改为 `|mean|`(**用户本人改的**,决策 38)——
|
|
||||||
均值为负时 `σ/μ` 会给出负值,容易被读成"波动更小";现在 CV 恒非负。docstring 的 `Notes` 同步,
|
|
||||||
并加一条测试钉住"均值为负时 CV 仍为正"。
|
|
||||||
- `tests/test_stats.py`:去掉**模块级** `pytestmark = pytest.mark.skip` —— 它把该文件 6 条测试
|
|
||||||
**全部**跳过(包括已经实现好的 `describe`),正是决策 26 要禁的写法:实现零覆盖而测试全绿。
|
|
||||||
改为逐条 skip 未实现的 5 条。
|
|
||||||
- 测试计数:**82 passed / 40 skipped → 103 passed / 39 skipped**(新增 21 条);
|
|
||||||
ruff 7 error → **All checks passed**;pyright **1 error → 0**。
|
|
||||||
|
|
||||||
- `stats.correlation_matrix`:实现落地(**由用户本人写**,Codex 审查 + 代修),修掉一个**硬 bug** ——
|
|
||||||
`if numeric_cols == 0:` 拿 `Index` 和标量比,得到的是布尔**数组**:0 列时静默不报错、
|
|
||||||
1 列时碰巧能跑、**≥2 列直接 `ValueError: The truth value of an array ... is ambiguous`** ——
|
|
||||||
而"≥2 个数值列"正是最常见的情况,等于整个函数不可用。改成 `len(numeric_cols) == 0`,
|
|
||||||
并加回归用例(见易错点 13)。
|
|
||||||
- `stats.correlation_matrix`:pyright 的两处报错**都不是运行时问题,也都没挂 `# type: ignore`** ——
|
|
||||||
`data[numeric_cols]` 是坑 8 的 `DataFrame | Series` 重载(`cast` 收窄);
|
|
||||||
`_, p = stats.pearsonr(...)` 解包后会退化成 `object`(scipy 的返回值是 `_make_tuple_bunch`
|
|
||||||
在运行时拼的类,存根里看不到 `pvalue`)。新增 `_pvalue(result: Any) -> float` 小助手统一取 p 值,
|
|
||||||
后面 `t_test` / `anova` / `chi_square_test` 可复用(见 `docs/04-environment.md` 坑 10、决策 40)。
|
|
||||||
- `stats.correlation_matrix`:`method` 校验提到最前(守卫集中开头);`add_stars` 的 `elif` 链改 `if`
|
|
||||||
(RET505);清掉 3 条 F541 与 1 条 I001(import 未排序)—— ruff 归零。
|
|
||||||
- `tests/test_stats.py`:`test_correlation_matrix_diagonal_is_one` 由 `skip` 改成 `xfail(strict=False)`
|
|
||||||
(决策 30)—— 契约要**数值**矩阵,实现返回带星标的字符串表,口径待拍板;
|
|
||||||
另新增 2 条(多列数值表能跑通、非法输入报错)。
|
|
||||||
- 测试计数:**103 passed / 39 skipped → 105 passed / 38 skipped / 1 xfail**。
|
|
||||||
|
|
||||||
- `stats.correlation_matrix`:按**方案 A** 拆开 —— 只返回**数值**矩阵,不再返回带星标的字符串表。
|
|
||||||
字符串表做不了后续计算(挑高相关对、喂聚类 / PCA、画热力图),而反过来随时能格式化;
|
|
||||||
一旦返回字符串,数值信息不可逆地丢失(见易错点 14)。
|
|
||||||
- `stats.correlation_pvalues`:**对角线由 `0.0` 改成 `NaN`** —— 写成 0 会被下游读成"极显著",
|
|
||||||
而"自己和自己相关"根本没有 p 值可言。
|
|
||||||
- `stats.correlation_pvalues`:p 值与 r 现在用**同一批观测** —— 先成对 `dropna()` 再算,
|
|
||||||
并同样尊重 `min_periods`(不足给 NaN)。此前 `corr` 是成对删缺失、`pearsonr` 不删,
|
|
||||||
两者基于不同的样本量,r 和 p 对不上。
|
|
||||||
- `stats.correlation_pvalues`:只算上三角再镜像到对称位置(成对变量的 p 天然对称),
|
|
||||||
省掉一半重复计算。
|
|
||||||
- `tests/test_stats.py`:`test_correlation_matrix_diagonal_is_one` 摘掉 `xfail` 转正;
|
|
||||||
新增 8 条(数值性 + 对称性、非数值列跳过、p 值对照**解析式** `t = r·sqrt((n-2)/(1-r²))`、
|
|
||||||
对角线 NaN、成对删缺失口径、`min_periods`、对角线无星标、表格与数值输出一致)。
|
|
||||||
- 测试计数:**105 passed / 38 skipped / 1 xfail → 114 passed / 38 skipped / 0 xfail**。
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进
|
|
||||||
`clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、
|
|
||||||
非数值列恒 `False`(决策 32)、入参类型校验补上(决策 33)。
|
|
||||||
S 档卡片与 `examples/` 用法也已落地 —— `detect_outliers` 这条线**结清**。
|
|
||||||
- `transform.py` 的 **4 个口径已全部收口**:① 三个函数共用 `_NUMERIC_TYPES` 白名单(含 `DataFrame`);
|
|
||||||
② 全部接受 `DataFrame` 且保持"DataFrame 进 DataFrame 出";③ 显式 `offset` 按定义域校验
|
|
||||||
(`min(x) + offset > 0`,见决策 35,**待用户确认**);④ 常量列行为写进 docstring 的 `Notes`;
|
|
||||||
另外 `Parameters` / `Returns` / `Raises` 已按 §4.7 补齐。
|
|
||||||
- 新增易错点第 10 条:**把真报错当成"编辑器误报"** —— 红线先跑 CLI 与 `pytest` 再判断(`docs/dev/personal-pitfalls.md`)。
|
|
||||||
- `stats` 这条线开工了:`describe` 落地,填掉 `examples/01_clean_and_describe.py` 的 `TODO(L0)`;
|
|
||||||
`correlation_matrix` 功能上也能跑了(硬 bug 已修)。
|
|
||||||
- `correlation_matrix` 的返回契约已按**方案 A** 定案(决策 41):数值归 `correlation_matrix`、
|
|
||||||
p 值归 `correlation_pvalues`、星标表归 `correlation_table`。对角线不带星、p 与 r 同批观测两条也一并修掉。
|
|
||||||
- `correlation_matrix` 仍只收 `DataFrame`(`describe` / `zscore` / `transform` 都收 ndarray)——
|
|
||||||
要不要放开由用户决定,记在 `STATUS.md`「下一步」第 5 条。
|
|
||||||
- 三处口径已拍板(2026-10-03):`log_transform` 的 `offset` 维持现状(决策 35);
|
|
||||||
`describe` / `zscore` **默认丢掉**非数值列、与 `detect_outliers` 的"保留列"刻意不统一(决策 37);
|
|
||||||
变异系数改用 `σ/|μ|`(决策 38)。另立决策 39 明确分工:语义口径用户拍板、工程事务 Codex 顺手补。
|
|
||||||
- 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `bootstrap_ci` / `viz`,
|
|
||||||
`Result` 的用法届时并进去。
|
|
||||||
- 已知缺口:
|
|
||||||
- 除 `core.Result`、`core.get_rng`、`core.set_seed`、`data.load_table`、`data.save_table` 外,其余函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。
|
|
||||||
这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。
|
|
||||||
- 已知缺口:`SALib`(Sobol/Morris)、`pymoo`、`pyright` 已装进 conda 环境 `mcm-toolkit`,`.venv` 里没有。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- `stats.confidence_interval`:修掉一个**会给出错误数字**的硬 bug —— 原码返回
|
|
||||||
`(float(min(0.0, lower)), float(max(upper, 1.0)))`,`min` / `max` 的**参数写反**(下界被钉死成 `0.0`),
|
|
||||||
而且**无条件**把区间夹到 `[0, 1]`。实测 `confidence_interval(rng.normal(170, 8, 50), method="t")`
|
|
||||||
的下界是 `0.0`(正确是 `171.0 ± 2.1`);`confidence=1.5` 被夹成 `(0.0, nan)` 而不报错。
|
|
||||||
现在**只有 `proportion` 夹到 `[0, 1]`**(决策 42),`normal` / `t` 直接返回 `mean ± z|t·se`;
|
|
||||||
`method` 非法改抛 `ValueError`、`confidence` 按 `(0, 1)` 开区间校验、样本量 < 2 抛 `ValueError`。
|
|
||||||
- `stats.confidence_interval` 的入参曾只收 `DataFrame`,而 docstring 的 Examples 传 `list`、
|
|
||||||
L0 用例传 `ndarray` —— **两条都会 `TypeError`**。现放宽到 `list` / `tuple` / `ndarray` / `Series` / `DataFrame`。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `stats.inference._statistic(result: Any) -> float` 助手,与 `_pvalue` 成对(决策 43)。
|
|
||||||
scipy 的 `TtestResult` 与 `PearsonRResult` 同源,都是 `_make_tuple_bunch` 运行时拼的类,
|
|
||||||
存根里 `.statistic` / `.pvalue` 都看不到。用户报告的 `t_test` 「307 / 308 两行报错」即此,
|
|
||||||
**不是真 bug**(探针实测 `TtestResult(statistic=3.873, pvalue=0.0305)`,运行时完全正常);
|
|
||||||
换成助手后 pyright 归零,**一行 `# type: ignore` 都没挂**。见 `docs/04-environment.md` 坑 10。
|
|
||||||
- `stats.t_test`(L1 提前落地):docstring 补齐 `Parameters` / `Returns` / `Raises` / `Notes`;
|
|
||||||
`_clean_data` 改用 `isinstance(data, pd.DataFrame)` 收窄(坑 8 的 `cast`);`alpha` 开区间校验;
|
|
||||||
`df` 按 Welch–Satterthwaite / pooled / paired 分别给。
|
|
||||||
- `tests/test_stats.py`:**+10 条(36 → 46)**,含把原先挂着 `skip` 的
|
|
||||||
`test_confidence_interval_matches_scipy` 转正。对照一律用解析式(z 公式、Welch `df` 公式、
|
|
||||||
`t = (mean − value)/(s/√n)`),不拿实现对自己。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:新增第 15 条「区间被无条件夹到 `[0, 1]`,且 `min` / `max` 参数写反」;
|
|
||||||
并把**停在 6 号就没人管**的索引表补齐到 15(只补索引,正文一个字没动)。
|
|
||||||
- `docs/04-environment.md`:坑 10 扩写成 `pvalue` / `statistic` 两条;顺手修掉残留的
|
|
||||||
`[FENCE]` 代码围栏占位符与一处重复的 `---`。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `STATUS.md`:L0 记为 **9/12**(另 L1 的 `confidence_interval` / `t_test` 提前落地);
|
|
||||||
校正测试数为 **162 条 / 125 passed / 37 skipped**;新增决策 42 / 43;「下一步」列出
|
|
||||||
**`t_test` 待用户拍板的 4 个口径**(单样本结论文案、`paired` 的 NaN 对齐、`value` 被静默忽略、
|
|
||||||
`DataFrame` 摊平多列)—— 按决策 39,Codex 只列选项、不代选。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `core.validate.as_array`:**所有收数值函数的统一入口**(决策 44)。
|
|
||||||
一次做完"类型白名单 + 转 `ndarray` + 报告原来是什么容器",返回 `(arr, kind)`,
|
|
||||||
`kind` 是 `"frame"` / `"series"` / `"array"`。**还原不代做** —— 调用方按 `kind` 手写,
|
|
||||||
因为 `describe` / `correlation_*` / `confidence_interval` / `t_test` 的输出天生与输入不同形。
|
|
||||||
入口三条硬规矩:`numeric_only` 按"输出是否与输入同形"选、**默认绝不摊平**(要摊平显式 `ndim=1`)、
|
|
||||||
**不删缺失值**(删缺失是各函数的语义)。见 `docs/02-architecture.md` §4.8。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **入口去重**:`normalize` / `standardize` / `log_transform` / `zscore` / `confidence_interval` /
|
|
||||||
`t_test` 六处各自手抄的 `isinstance(x, pd.DataFrame)` / `select_dtypes` / `np.asarray` 全部删掉,
|
|
||||||
各只剩 1 行 `as_array(...)`;`data/transform.py` 的 `_NUMERIC_TYPES` 白名单连同 3 份副本一起删除。
|
|
||||||
原来这类判断散在 **5 个文件 31 处**。
|
|
||||||
- `data.transform` 的 `normalize` / `standardize` / `log_transform` 走 `numeric_only=False`:
|
|
||||||
它们的输出必须与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。
|
|
||||||
- `zscore` / `normalize` / `standardize` / `log_transform` 的 Series 分支**补回 `name`** ——
|
|
||||||
此前 `pd.Series(result, index=...)` 会把 `name` 丢掉,管道里 `concat` 会变成无名列。
|
|
||||||
- 测试:`tests/test_core.py` 新增 `TestAsArray` **8 条**(五种容器的 `kind`、只取数值列、
|
|
||||||
`numeric_only=False` 遇文本报错、默认不摊平、`ndim=2` 只校验不重塑、白名单外一律 `TypeError`、
|
|
||||||
**保留 NaN**、`dtype=None` 保持原 dtype);`tests/test_stats.py` 新增 1 条(`zscore` 保住 Series 的
|
|
||||||
`index` 与 `name`)。全量 **171 条 / 134 passed / 37 skipped**。
|
|
||||||
- `docs/02-architecture.md`:新增 §4.8「入口只有一个:`core.validate.as_array`」;
|
|
||||||
目录树里 `validate.py` 补上 `as_array`。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:新增第 16 条「同一段入口转换抄了 N 遍,还当它是约定」,索引补齐到 16。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- `normalize(含文本列的表)` 等"容器对、内容转不成 float"的情况,错误类型由 numpy 的 `ValueError`
|
|
||||||
统一收成 `TypeError`(决策 45,**Codex 代选、待用户确认**)—— 与 `t_test` / `confidence_interval`
|
|
||||||
已经写进 docstring 的口径一致。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `stats.bootstrap_ci`(**L0 第 10 项,S 档**,实现由用户本人写):百分位法 bootstrap 置信区间,
|
|
||||||
`statistic` 可以是任意 `f(sample) -> float`(默认 `np.mean`)。返回 `Result`,
|
|
||||||
`params = {estimate, low, high, confidence, n_resamples}`、`metrics.statistic_name`。
|
|
||||||
重采样**按块处理**(`batch_size=1000`),不落地 `B × n` 整矩阵。
|
|
||||||
- `docs/algorithms/bootstrap_ci.md`:**第 2 张 S 档函数卡片**。除模板九节外,记了一条非平凡性质 ——
|
|
||||||
**统计量有上界时百分位区间会塌到上界**(极差的重采样值永远 ≤ 原样本极差,实测 `high == estimate`),
|
|
||||||
以及 5 个待拍板口径。`docs/algorithms/README.md` 的进度表同步打勾。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- `bootstrap_ci` 的 `random_state=None` 分支原来另开 `np.random.default_rng()`,**绕过了 `core.get_rng`** ——
|
|
||||||
与决策 24 冲突:`set_seed` 对它无效、区间不可复现。改成无条件 `get_rng(random_state)`(决策 46)。
|
|
||||||
- `bootstrap_ci` 的 `metrics["statistic_name"]`:`hasattr(statistic, "__name__")` 判不出 lambda ——
|
|
||||||
lambda 的 `__name__` 恒为 `"<lambda>"`,于是 `else "custom"` 是**死代码**,自定义统计量拿不到 `"custom"`。
|
|
||||||
改成 `getattr(f, "__name__", None)` 再判 `name in (None, "<lambda>")`(易错点 17)。
|
|
||||||
- `bootstrap_ci` 的 docstring 补齐 `Parameters` / `Returns` / `Raises`(§4.7),并如实写明
|
|
||||||
`method="bca"` **尚未实现**(此前 docstring 承诺支持、实现却抛 `NotImplementedError`)。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `tests/test_stats.py`:**摘掉 3 条 `skip`**(等 `bootstrap_ci` 的那 3 条),并新增 6 条断言 ——
|
|
||||||
`estimate` 等于原样本统计量、`confidence` 单调性、五种容器结果一致、自定义 lambda 走回退分支、
|
|
||||||
**不给种子时跟随 `set_seed`**、非法/未实现 `method`。bootstrap 段共 9 条。
|
|
||||||
全量 **177 条:143 passed / 34 skipped**;ruff 0,pyright 0。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:**第 9 条「实现写完了测试还挂着 skip」复发 +1**(这次是 `bootstrap_ci` ——
|
|
||||||
实现写完、3 条测试仍挂 skip、全量照样全绿);新增第 17 条「`hasattr(f, "__name__")` 挡不住 lambda,
|
|
||||||
`else` 分支是死代码」;索引补到 17。
|
|
||||||
- `STATUS.md`:E 节记 `bootstrap_ci`(L0 10/12),决策 46,「下一步」改写并新增第 8 条列 5 个待拍板口径。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `tests/test_solve.py`:模块级 `pytestmark = pytest.mark.skip(...)` 拆成 **9 条逐条**
|
|
||||||
`@pytest.mark.skip(reason=_NOT_IMPLEMENTED)` —— 模块级 skip 会把整个文件吞掉(决策 26、易错点 9)。
|
|
||||||
用例数与跳过数不变(177 收集 / 143 passed / 34 skipped),仅粒度变细。
|
|
||||||
- `STATUS.md`:新增**分工硬规矩(决策 47)** —— 算法一律由用户本人写(即便用户改口也不代写)、
|
|
||||||
工程事务由 Codex 写;L0 计数 9/12 → **10/12**;「下一步」新增「⭐ 明天从这里开始」区块
|
|
||||||
(`monte_carlo` 的契约 / 向量化要求 / 三条测试建议 / 卡片路径 / 一个待拍板口径);
|
|
||||||
验证结果里的 skip 说明由 37 条订正为 **34 条**。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `solve.monte_carlo` 落地(**L0 第 11 项,S 档**,实现由用户本人写):向量化"采样 → 仿真 → 汇总",
|
|
||||||
返回 `Result`(8 个 metrics:mean / std / median / p05 / p95 / ci_low / ci_high),随机数无条件走 `get_rng`。
|
|
||||||
配套 `tests/test_solve.py` **10 条**测试(含"`simulator` 只被调用一次"这条可执行的向量化断言)。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- `monte_carlo` 的**布尔结果**此前让 `np.std` 在 numpy 2 里抛 `numpy boolean subtract ... is not supported`,
|
|
||||||
整个函数判失败 —— 而"事件是否发生"是 MC 最常见的用法。入口改为 `np.asarray(..., dtype=float)`(布尔按 0/1 统计);
|
|
||||||
字符串结果也从"ufunc isnan 不支持"变成可读的 `could not convert string to float`。由行为探针挖出。
|
|
||||||
- `monte_carlo` 的 docstring `Examples` 跑不通:原采样器返回标量,实测 `success=False`
|
|
||||||
("simulator 返回的结果数量(1)必须与模拟次数 n(1000)一致");改成向量化写法。
|
|
||||||
- `monte_carlo` 的 ruff 三条(I001 合并同源 import、F541 空 f-string ×2)、`success= True` 的空格、
|
|
||||||
失败文案"重采样" → "模拟"(从 `bootstrap_ci` 抄过来忘改)。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `monte_carlo` 按 §4.7 收严 `n`:`np.int64` 不再被误拒(注解放宽为 `int | np.integer`);
|
|
||||||
`n=True` / 浮点 `n` 抛 `TypeError`;`n<=0` 抛 `ValueError`。
|
|
||||||
- 用户第二轮改的三处口径(决策 48):批量大小改由实现传 `sampler(rng, size=n)`;结果含 NaN 判 `success=False`;
|
|
||||||
`np.std` 显式 `ddof=0`。docstring 同步改写(`sampler` 契约、`Raises`、`Notes` 的 NaN 口径)。
|
|
||||||
- `STATUS.md`:L0 10/12 → **11/12**,新增决策 48;验证计数更新为 **187 收集 / 153 passed / 34 skipped**。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 工作约定收窄(**决策 49**):**入口没收紧 / 少考虑情况 / 不符合简单原则**这三类改动交回用户本人写,
|
|
||||||
Codex 只报告 + 列选项(判据:这处改动要不要"想一下才对")。决策 47 里"入口 / 校验重构归 Codex"的部分作废。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:新增 **第 18 / 19 / 20 条**(入口校验没照 §4.7 清单逐条过 / 只想着主路径那种值 /
|
|
||||||
"不简单"的信号是同一个信息要写两遍),第 1 条(类型判断只写 `int`)**复发 +1**,索引补到 20。
|
|
||||||
- `STATUS.md`:新增决策 49 与两个待拍板小口径(`random_state` 注解是否全项目对齐、`std` 的 `ddof` 是否统一)。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `solve.monte_carlo_probability` 落地(**L1,S 档**,实现由用户本人写):向量化 `event(batch) -> bool 数组`,
|
|
||||||
返回 `Result`(`probability` / `ci_low` / `ci_high` / `std_error`),区间是 Wald 区间并夹在 `[0, 1]`(沿用决策 42 口径)。
|
|
||||||
`tests/test_solve.py` 新增 5 条 + **摘掉 2 条 skip**(实现已写完却还挂着,易错点 9),`solve` 段共 24 条。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- `monte_carlo_probability` 的 docstring `Examples` 跑不通:原采样器返回标量,实测 `success=False`
|
|
||||||
("event 返回的结果数量(1)必须与模拟次数 n(2000)一致");改成批量采样写法并实测通过。
|
|
||||||
- `monte_carlo_probability` 的 ruff 六条:SIM300(`0.0 >= confidence` 写成 Yoda 条件)、W291 ×2、W293 ×2、F541。
|
|
||||||
- 该函数 NaN 提示里的 "simulator" 改成 "event"(从 `monte_carlo` 抄来没改);`metrics` 缩进与 `{"n":n}` 空格。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `monte_carlo_probability` 的 docstring 补齐 `Parameters` / `Raises`(原文只有 Returns / Notes / Examples),
|
|
||||||
并把"Wald 区间 + 夹到 [0,1]"与"`p <= 1e-3` 判失败"两条现状如实写进 Returns / Notes。
|
|
||||||
- `STATUS.md`:L1 清单加入 `monte_carlo_probability`;验证计数更新为 **192 收集 / 160 passed / 32 skipped**;
|
|
||||||
「下一步」新增该函数的 **6 条待办口径**(全部留给用户,Codex 未动)。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`monte_carlo_probability` 的 `z_score^2` 是异或不是乘方**(Python `^` 语义):`np.float64 ^ 2` 直接抛
|
|
||||||
`TypeError: ufunc 'bitwise_xor' not supported`,被 `except Exception` 吞成 `success=False` —— 函数实际上**整个不可用**,
|
|
||||||
6 条测试全红才暴露。改成 `z_score**2`。新增经验第 21 条。
|
|
||||||
- `monte_carlo_probability` 的 message 在没有警告时留了个尾逗号;`{"confidence":confidence}` 补空格。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 用户第二轮改定(**决策 50**):区间由 **Wald 换成 Wilson score**(实测 p=1、n=1000 从退化的 `(1.0, 1.0)`
|
|
||||||
变成 `(0.99617, 1.0)`;p=0 从"判失败"变成 `(0.0, 0.00383)`);小概率不再判失败,改为 message 里的警告;
|
|
||||||
`confidence` 记进 `Result.params`;`callable` 入口校验(现在抛 `TypeError`);`float()` 统一 `ci_*` 类型;
|
|
||||||
`n` 注解放宽为 `int | np.integer`。
|
|
||||||
- `tests/test_solve.py`:新增 1 条对照测试(区间必须等于 `statsmodels.proportion_confint(method="wilson")`),
|
|
||||||
共 25 条;全量 **193 收集 / 161 passed / 32 skipped / 0 xfail**。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:新增第 **21** 条(公式里的 `^` 不是乘方,且被 `except` 吞成 `success=False`),索引补到 21。
|
|
||||||
- docstring 的 "Wald 区间" 同步改为 "Wilson score 区间"。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `solve.monte_carlo_integral` 落地(**L1,S 档**,实现由用户本人写):`bounds` → 自造 `(n, d)` 均匀样本 → 逐点求值 →
|
|
||||||
`mean(func(X) × 体积)`;内部自造 `sampler` / `simulator` 再复用 `monte_carlo` —— "造批 → 一次算 → 汇总"的第二次复用。
|
|
||||||
`params` 额外记 `bounds` / `dimension`;配套 `tests/test_solve.py` 新增 4 条 + 摘 1 条 skip。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`monte_carlo_integral` 的体积符号写反**(真 bug,按决策 31 修):写成 `wights = lows - highs`,奇数维体积为负 ——
|
|
||||||
1D `x²` 于 `[0,1]` 实测 **-0.3324**(应 +1/3)、3D `x` 得 **-0.5005**(应 +1/2);**2D `x·y` 却得 +0.2499 "看着对"**
|
|
||||||
(两个负号相乘抵消,把 bug 藏住)。改成 `widths = highs - lows` 后复测 **0.3324 / 0.5005 / 1.9971(`[0,2]`)** 全部正确。
|
|
||||||
- `monte_carlo_integral` 的 docstring `Examples` 跑不通:断言 `abs(r.data - 1 / 3) < 0.01`,而 `data` 是 n 个样本的贡献数组,
|
|
||||||
`bool(数组)` 会抛 `ValueError: truth value ... is ambiguous`;改成断言 `metrics["mean"]`。
|
|
||||||
- 格式:`simulate.py` 里 `def random_walk` 前只留了 1 个空行;`tests/test_solve.py` 有 3 处留了 3 个空行。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `monte_carlo_integral` 的 docstring 补齐 `func` / `n` / `random_state` 三节 `Parameters` 与 `Raises`(原文只有 `bounds` +
|
|
||||||
Notes + Examples);`Returns` 写清"**`metrics["mean"]` 才是积分估计值**,`data` 是 n 个样本各自的贡献"(**决策 51**)。
|
|
||||||
- `tests/test_solve.py`:摘掉 `test_monte_carlo_integral_converges_to_analytic_value` 的 skip,新增 3 条"抓符号"用例
|
|
||||||
(3D 奇数维、`[0,2]` 宽度、2D + `name`/`dimension`/`bounds` 三个 params)与 1 条非法 `bounds` 抛 `ValueError`;
|
|
||||||
`solve` 段 25 → **30** 条,全量 **197 收集 / 166 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
|
|
||||||
- `STATUS.md`:L1 清单加入 `monte_carlo_integral`;新增**决策 51** 与「`monte_carlo_integral` 的 3 条待办」;
|
|
||||||
「下一步」改成从 `random_walk` 接着写;验证计数更新为 166 / 31;`monte_carlo_probability` 的 6 条待办收窄到 1 条。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:新增第 **22 / 23** 条(失败结果的 `message` 被包装层无条件覆盖;体积/宽度也会减反、
|
|
||||||
维度奇偶会把符号错误藏住),第 3 条(注解与 docstring 不一致)**复发 +1**,索引补到 23。
|
|
||||||
- `tmp/type_demo.py`(gitignored):用 pyright 证明 `Callable[..., bool]` 会**拒绝**一个注解良好的向量化 `event`
|
|
||||||
(`ndarray` 与 `bool` 不兼容),`Callable[..., Any]` 才收 —— 这是决策 50 那条遗留的实际代价。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- `monte_carlo_integral` 的 `bounds` 形状校验缺失(用户第二轮补齐):`bounds=[0, 1]` 从裸 `IndexError: too many indices`
|
|
||||||
变成可读 `ValueError`(带当前形状与"请检查是否漏了括号");`[]` / 非 `list`-`tuple` / 参差不齐的输入也一并收口。
|
|
||||||
- `monte_carlo_integral` 的失败 `message` 被包装层覆盖(用户第二轮改成 `if result.success:` / `else:` 两支):
|
|
||||||
`func` 返回标量时不再是 `success=False` + "成功"文案,而是"蒙特卡洛多重积分失败:维度 1,体积 1.0000,模拟 100 次。
|
|
||||||
蒙特卡洛模拟失败:TypeError - object of type 'float' has no len()"。
|
|
||||||
- 格式:`simulate.py` 两行"只有空格的行"(ruff W293 ×2,Codex 顺手补)。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `monte_carlo_probability` 的 `event` 注解 `Callable[..., bool]` → `Callable[..., Any]`(用户改,**决策 50 的遗留清零**):
|
|
||||||
pyright 实测原注解会**拒绝**一个注解良好的向量化事件函数(`ndarray` 与 `bool` 不兼容),运行期却一直收得下。
|
|
||||||
- `monte_carlo_integral` 的 `n` 注解放宽为 `int | np.integer`(与全模块对齐,易错点 1 的老口径)。
|
|
||||||
- `monte_carlo_integral` 的 docstring:`bounds` 入参说明与 `Raises` 同步到新入口校验(补"非空 `list`/`tuple`、形状 `(d, 2)`")。
|
|
||||||
- `tests/test_solve.py`:非法 `bounds` 从 1 类扩到 4 类(`[(1, 0)]` / `[0, 1]` / `[]` / 参差不齐),新增"失败 message 不许出现
|
|
||||||
'成功'"与 `np.int64` 两条;`solve` 段 30 → **32** 条,全量 **199 收集 / 168 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
|
|
||||||
- `STATUS.md`:integral 的 3 条待办标记办结、概率函数的 6 条待办全部办结;新增**决策 52** 与 2 条新口径
|
|
||||||
(`bounds` 收不收 ndarray、`if`/`else` 里那三行重复);验证计数更新为 168 / 31。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `solve.random_walk` 落地(**L1,S 档**,实现由用户本人写):`step_sampler(rng, size=(n_paths, n_steps))` 一次抽完整批增量 →
|
|
||||||
`np.cumsum(..., axis=1)` → 首列填 `start`、其余列 `start + cumsum`;`data` 形状 `(n_paths, n_steps + 1)`。
|
|
||||||
配套 `tests/test_solve.py` **新增 6 条**(此前这个函数一条测试都没有)。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`random_walk` 的默认采样器整个不可用**(真 bug,按决策 31 修):`rng.integers(0, 2, size=size, dtype=float)`
|
|
||||||
被 numpy 拒掉(`TypeError: Unsupported dtype dtype('float64') for integers`)—— 默认路径 100% `success=False`、
|
|
||||||
`data=None`,连 docstring 的 Examples 都跑不通。改成 `rng.integers(0, 2, size=size) * 2 - 1`
|
|
||||||
(由后面的 `np.asarray(..., dtype=float)` 统一转 float);实测形状 `(5, 101)`、步长恒 ±1、同种子可复现。
|
|
||||||
- 格式:`simulate.py` 又两行"只有空格的行"(ruff W293 ×2)。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `monte_carlo_integral` 按用户第二轮口径收口(**决策 52**):`bounds` 现在也收 `np.ndarray`;
|
|
||||||
`name` / `params["bounds"]` / `params["dimension"]` 从 `if` / `else` 两支提到 `if` 之前,只让 `message` 分两支。
|
|
||||||
- `random_walk` 的 docstring 补全(原文只有 `step_sampler` 一节):补齐 `n_steps` / `n_paths` / `start` / `random_state`
|
|
||||||
与 `Returns` / `Raises` / `Notes`,并写清 `step_sampler` 收的是**二维** `size=(n_paths, n_steps)`。
|
|
||||||
- `tests/test_solve.py`:新增 6 条 `random_walk` 测试(形状与 `start` 列、默认步长恒 ±1、同种子可复现、
|
|
||||||
自定义高斯采样器、3 类非法入参、docstring Example 真能跑);`solve` 段 32 → **38** 条,
|
|
||||||
全量 **205 收集 / 174 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
|
|
||||||
- `STATUS.md`:新增**决策 53** 与 `random_walk` 的 4 条待办;`monte_carlo_integral` 的 2 条新口径办结 1 条
|
|
||||||
(剩"第一道 `isinstance` 分支已冗余");「下一步」推进到 `markov_simulate` / `markov_steady_state`。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`random_walk` 的失败路径全部炸成 `UnboundLocalError`**(真 bug,按决策 31 修):`call_params = {...}` 写在 `try:` 里,
|
|
||||||
而 `except` 分支要引用它 —— 实测 5 种失败输入(形状不对 / 增量含 NaN / 采样器抛异常 / `start` 非数值 / 返回 1-D 数组)
|
|
||||||
**5 个全中**,本该返回 `success=False` 的 `Result`,变成把异常抛给调用方。按原意把 `call_params` 前移到 `try` 之前,
|
|
||||||
未改任何口径;失败时照样带上 `n_steps` / `n_paths` / `start` / `random_state`。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `random_walk` 按用户第二轮口径改定(**决策 54**):`n_steps` / `n_paths` 拆成 `TypeError`(类型)/ `ValueError`(取值);
|
|
||||||
新增 `steps.shape != (n_paths, n_steps)` 与 `np.isnan(steps).any()` 两条校验;默认步长改 `rng.choice([-1.0, 1.0], size=size)`;
|
|
||||||
`params` 补 `random_state`;`metrics` 由"抄 params"改成 `mean_terminal` / `std_terminal`(终点位置,`ddof=0`)。
|
|
||||||
- `random_walk` 的 docstring 同步:`Returns` 写实际 metrics、`Raises` 改成"类型错 `TypeError` / 取值错 `ValueError`"、
|
|
||||||
去掉已不存在的 `start` 校验、`Notes` 记明形状与 NaN 都判失败。
|
|
||||||
- `tests/test_solve.py`:+3 条("失败也必须返回带 params 的 Result"、NaN 判失败、终点 `std ≈ √t`),
|
|
||||||
`start="a"` 改为断言 `success is False`(口径待定);`solve` 段 38 → **41** 条,
|
|
||||||
全量 **208 收集 / 177 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
|
|
||||||
- `STATUS.md`:新增**决策 54** 与 `random_walk` 的 3 条新口径;4 条待办标记办结;验证计数更新为 177 / 31。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:新增第 **26** 条(`except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸)。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `random_walk` 收口 2 条口径(用户第二轮改动):恢复 `start` 的入口类型校验(`isinstance(start, (int, float, np.number))`);
|
|
||||||
签名注解放宽为 `n_steps: int | np.integer` / `n_paths: int | np.integer`(与 §4.7 及 `monte_carlo_integral` 的 `n` 对齐)。
|
|
||||||
- `random_walk` 的 `start` 报错文案(Codex 顺手):原文"start必须是整数"与校验本身矛盾 —— 这条校验**收 float**,
|
|
||||||
改成"start 必须是数值";类型名统一 `{type(start).__name__}`(全模块口径);`n_paths: int | np.integer= 1` 补空格。
|
|
||||||
- `random_walk` 的 docstring 同步:`start` 的说明去掉"入口不校验类型",`Raises` 补回 `start` 的 `TypeError`。
|
|
||||||
- `tests/test_solve.py`:`start="a"` 恢复断言 `TypeError`;补回 `np.int64` 的 `n_steps` / `n_paths`;
|
|
||||||
`solve` 段 41 → **42** 条,全量 **209 收集 / 178 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
|
|
||||||
- `STATUS.md`:新增**决策 55**(`n_paths == n_steps` 的"方阵歧义"留作已知边界,A 接受 / B 换契约两条路);
|
|
||||||
3 条口径办结 2 条,验证计数更新为 178 / 31。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `random_walk` 的 `step_sampler` 契约按**方案 B** 重定(用户拍板,**决策 55**):`size` 由 `(n_paths, n_steps)` 改为**整数**
|
|
||||||
`n_paths * n_steps`,必须返回**一维**数组,内部 `reshape(n_paths, n_steps)`(行主序)。
|
|
||||||
**动因**:转置 `(n_steps, n_paths)` 与目标 `(n_paths, n_steps)` 在 `n_paths == n_steps` 时形状相同,形状校验天生分不出来(易错点 25)。
|
|
||||||
探针实测:默认采样器 `(5, 101)` 正常;转置二维 / 方阵二维 / 长度 +1 / `(N, 1)` 四种返回一律判失败。
|
|
||||||
**关键点:不要用 `.ravel()` 救场**,那会把转置的二维摊平、歧义原样回来。红利:与 `monte_carlo` 的 `sampler`(决策 48 的 `size=n`)
|
|
||||||
契约统一,给 `monte_carlo` 写的采样器可直接复用(实测通过)。
|
|
||||||
- `random_walk` 的 docstring 同步:`step_sampler` 说明改为"整数 `size` + 一维返回 + 行主序",`Notes` 记明二维返回一律判失败。
|
|
||||||
- `tests/test_solve.py`:把"转置采样器"那条改成 `test_random_walk_rejects_2d_step_sampler`(含方阵情形),
|
|
||||||
新增 `test_random_walk_step_sampler_gets_flat_size_and_is_row_major`(验 `size == 6` 与 `cumsum` 的行主序结果);
|
|
||||||
`solve` 段 42 → **43** 条,全量 **210 收集 / 179 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
|
|
||||||
- `STATUS.md`:决策 55 改为"已选 B 并落地";`random_walk` 的 7 条待办全部办结;验证计数更新为 179 / 31。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `solve.markov_simulate` 落地(**L1,S 档**,实现由用户本人写):`arr[current_states]` 取当前状态那一行 →
|
|
||||||
`cumsum` 得累积分布 → 用 `[0, 1)` 均匀数"落格"(`(cum_probs < r[:, None]).sum(axis=1)`)→ 写进下一列;
|
|
||||||
`data` 形状 `(n_paths, steps + 1)`、值是整数状态编号。Codex 审查结论:**算法本体正确**
|
|
||||||
(探针实测 200 步后终点频率 `[0.657, 0.343]` 对照平稳分布 `[2/3, 1/3]`、同种子可复现)。
|
|
||||||
- `tests/test_solve.py`:新增 9 条 `markov_simulate` 测试(此前 0 条)—— 形状 / 首列 / 状态取值 / 整数 dtype、
|
|
||||||
单位矩阵吸收态自锁、可复现(同种子相同、异种子不同)、终点频率收敛到 `[2/3, 1/3]`、metrics = 终点状态、
|
|
||||||
非法转移矩阵(非方阵 / 行和不为 1)、非法 `steps` / `n_paths`(0 / float / bool)、`np.int64` 可收、docstring Example 真能跑。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `markov_simulate` 的 docstring 从一行补到完整的 `Parameters` / `Returns` / `Raises` / `Notes` / `Examples`。
|
|
||||||
- `markov_simulate` 的工程侧清理:`kind` 全程未用 → `arr, _`;`马尔科夫` → `马尔可夫`(3 处)、
|
|
||||||
`initail` → `initial`、半角逗号 → 全角;`paths[:,0]` → `paths[:, 0]`;ruff 8 条全清(`I001` + `W291` / `W293`)。
|
|
||||||
- `STATUS.md`:新增**决策 56**(`initial` 的语义待拍板,含 A / B / C 三个选项)与 5 条待办;
|
|
||||||
验证计数更新为 **188 passed / 31 skipped(219 收集)**。
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- **`markov_simulate` 的 5 条口径留给用户**(决策 49):`initial` 语义(标量按路径数 / 数组按状态数,两条口径互相矛盾)、
|
|
||||||
转移矩阵未查非负(行和恰为 1.0 的 `[[1.5, -0.5], [0, 1]]` 会静默给出全 0)、`paths[:, 0]` 赋值在 `try` 之外
|
|
||||||
(长度不匹配时裸抛 `ValueError`)、状态越界没在入口拦、`std_terminal` 对分类状态的意义。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `solve.markov_steady_state` 落地(**L1,S 档**,实现由用户本人写):`v` 从均匀分布出发,`v_next = v @ arr`
|
|
||||||
反复推进(行随机矩阵按行向量右乘),直到 `max|v_next - v| < tol` 或耗尽 `max_iter`;
|
|
||||||
`data` 是平稳分布向量、`metrics={"converged", "iterations"}`。Codex 审查结论:**算法本体正确** ——
|
|
||||||
3 状态与 `np.linalg.eig(P.T)` 特征值 1 的特征向量逐位一致、200×200 一致到 `1.9e-12`(6 次迭代 / 0.10s)、
|
|
||||||
与 `markov_simulate` 的终点频率互验 `[0.6702, 0.3298]` vs `[0.6667, 0.3333]`。
|
|
||||||
- `tests/test_solve.py`:摘掉 `test_markov_steady_state_two_state_chain` 的 skip,另新增 7 条 ——
|
|
||||||
与特征分解对拍、`data` 是分布(和为 1 / 非负 / `float64`)、慢混合链迭代次数 > 100、`np.int64` 的 `max_iter`、
|
|
||||||
非法转移矩阵、`max_iter=0` / `tol=0`、docstring Example 真能跑。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `markov_steady_state` 的 docstring 补全:`max_iter` / `tol` 两个参数、`Raises` 一节,
|
|
||||||
并写清 `tol` 是**相邻差口径**(实测慢混合链 `tol=1e-10` 的实际误差 `4.6e-9`,被 `1/(1-|λ2|)` 放大约 50 倍)。
|
|
||||||
- `markov_steady_state` 的 Notes 修正"周期链会不收敛"的说法:可约链的实情是**平稳分布不唯一**,
|
|
||||||
迭代法给出的是"离初值最近的那个"(均匀初值对双随机矩阵本身就是不动点,所以 `iterations=1` 不代表链唯一平稳)。
|
|
||||||
- `markov_steady_state` 的工程侧清理:ruff 5 条 `W293`;`mm1_metrics` 前 4 个空行 → 2;失败文案 `失败: ` → `失败:`。
|
|
||||||
- `STATUS.md`:新增**决策 57**(6 条待拍板口径)与「下一步」第六轮;验证计数更新为 **196 passed / 30 skipped(226 收集)**。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:新增易错点 28(数值阈值参数的 `bool` / NaN 漏网)。
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- **`markov_steady_state` 的 6 条口径留给用户**(决策 49):转移矩阵未查非负(行和恰为 1.0 的 `[[1.5, -0.5], [0, 1]]`
|
|
||||||
会溢出成 `nan`,而 `data=[nan, nan]` 却 `success=True`,违反决策 48 的 NaN 口径;附带的 `RuntimeWarning`
|
|
||||||
在 pyproject 里被设成 error,**测试会直接报错**)、`tol=True` 被当 `tol=1` 静默放行(`isinstance(True, int)`)、
|
|
||||||
`tol=np.nan` 穿过校验白跑迭代、`converged=False` 仍 `success=True`、
|
|
||||||
类型错也抛 `ValueError`(与决策 54 分工不一致)、`params` 没记转移矩阵。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `solve.mm1_metrics` 落地(**L1,S 档**,实现由用户本人写):闭式解 `ρ = λ/μ`、`L = ρ/(1-ρ)`、
|
|
||||||
`Lq = ρ²/(1-ρ)`、`W = 1/(μ-λ)`、`Wq = W - 1/μ`;`λ >= μ` 走"系统不稳定"分支返回 `success=False`。
|
|
||||||
Codex 审查结论:**公式全对**(探针实测 3 组参数 `(2,3)` / `(1,4)` / `(0.5,1.0)` 与闭式公式逐位一致,
|
|
||||||
`W = Wq + 1/μ` 恒等式成立)。至此 `solve` 层的 L1 清单**全部落地**。
|
|
||||||
- `markov_simulate` / `markov_steady_state` 增加**非负校验**(用户 2026-10-09 改定):含负元素的行随机矩阵
|
|
||||||
现在入口即报错,不再静默给出错结果(前者会"全落 0",后者会溢出成 `nan` 却报成功)。
|
|
||||||
- `markov_steady_state` 的 `params` 补 `transition`(`arr.tolist()`);`success=converged`
|
|
||||||
(未收敛判失败,口径 B);`tol` 拆成 `TypeError` / `ValueError` 且先判 `bool`。
|
|
||||||
- `tests/test_solve.py`:摘掉 2 条 `mm1_metrics` 的 skip,新增 4 条(含 `λ == μ` 的回归测试);本轮累计 +6 条。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **`mm1_metrics` 的稳定性分界写错**(真 bug,按决策 31 修):`service_rate < arrival_rate` 与 docstring / 既有测试的
|
|
||||||
`λ >= μ` 口径不符 —— `λ == μ` 会掉进 `try`、`rho == 1.0` → `ZeroDivisionError`,被吞成 `success=False`
|
|
||||||
(结果碰巧对,但 message 成了"计算失败:ZeroDivisionError - float division by zero")。改成 `<=` 并加回归测试;
|
|
||||||
**两个分支的可观测输出完全相同(`success=False` / `metrics={}` / `data=None`),故未动任何口径**。
|
|
||||||
- **三处报错文案的参数名抄错**:`markov_steady_state` 的 `max_iter <= 0` 写成"tol 必须是正数";
|
|
||||||
`mm1_metrics` 的 `service_rate` 两条报错都写成 "tol"。顺带把半角逗号统一成全角。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `mm1_metrics` 的 docstring 补 `Raises` 与 `data` 说明;`call_params = {{...}}` 补空格;
|
|
||||||
ruff 6 条(5 处 `W293` + `F541` f-string 无占位符 + `W292` 文件末尾无换行)。
|
|
||||||
- `markov_steady_state` 的 `try` 前 3 个空行 → 1;`异常: ` → `异常:`。
|
|
||||||
- `STATUS.md`:决策 56 / 57 更新办结项,新增**决策 58**(`mm1_metrics` 的 4 条待拍板);「下一步」推进到第七轮;
|
|
||||||
验证计数更新为 **202 passed / 28 skipped(230 收集)**。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:新增易错点 29(同一个校验里的两把尺子)。
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- **待用户拍板的口径**(Codex 只报不改):`markov_simulate` 4 条(`initial` 语义 A/B/C、`paths[:, 0]` 赋值位置、
|
|
||||||
状态越界、`std_terminal` 对分类量)、`markov_steady_state` 3+2 条(`tol` 的 NaN、`max_iter` 类型错、
|
|
||||||
非负校验的**容差**、未收敛却带 `data`)、`mm1_metrics` 4 条(`λ = 0`、numpy 标量、`data=metrics`、
|
|
||||||
失败分支丢 `rho`)。详见 `STATUS.md` 决策 56 / 57 / 58。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `tests/test_solve.py`:本轮再 +2(`markov_simulate` 的 `terminal_distribution` 口径、
|
|
||||||
`mm1_metrics` 的 `λ = 0` 空系统),并把受口径变更影响的 5 条测试同步到新行为。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `markov_simulate`:非负校验带容差 `-1e-12`(浮点归一化的 `-1e-16` 残渣不再被误判);
|
|
||||||
`initial` 收 `np.integer`;`paths` 构造与首列赋值挪进 `try`(长度不匹配 / 状态越界不再裸抛);
|
|
||||||
`params` 键 `n_steps` → `steps`、`initial` 存成可 JSON 化的标量 / 列表;
|
|
||||||
`metrics` 的 `std_terminal` → `terminal_distribution`(`{状态: 频率}`,只列出现过的状态)。
|
|
||||||
- `markov_steady_state`:非负校验带容差;`max_iter` 类型错改抛 `TypeError`;`tol` 增加 `np.isnan` 拦截;
|
|
||||||
非方阵改抛 `TypeError`。
|
|
||||||
- `mm1_metrics`:`arrival_rate` 放开为**非负**(`λ = 0` = 空系统,`L = Lq = Wq = 0`、`W = 1/μ` 取极限值);
|
|
||||||
两个速率收 `np.number`;不稳定分支保留 `rho` 并写进 message;`data=metrics.copy()`(去掉同一个 dict 对象的别名)。
|
|
||||||
- docstring 同步:`markov_simulate` 的 `Returns` 与"遗留"段、`markov_steady_state` 的 `Raises`(TypeError / ValueError 分工)、
|
|
||||||
`mm1_metrics` 的 `Parameters` / `Returns` / `Raises`。
|
|
||||||
- ruff:`B905`(`zip` 缺 `strict`)+ 2 条空白 → 0;`markov_simulate` 里 `rng` 之后的双空行 → 1。
|
|
||||||
- `STATUS.md`:决策 56 / 57 / 58 更新到"只剩 5 条小口径",「下一步」推进到第八轮,
|
|
||||||
并写入**第八轮备忘:`models` 与 `decision` 各是干什么的**(含两包的 19 / 8 个函数清单、使用顺序、建议开工顺序)。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:新增易错点 30(口径一改,docstring / 测试 / STATUS 三处会同时过期)。
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- **只剩 5 条口径待拍板**(Codex 只报不改):`markov_simulate` 的 `bool` 漏网与 `initial` 的 A/B/C、
|
|
||||||
`markov_steady_state` 未收敛时是否带 `data`、非方阵的 `TypeError` / `ValueError` 不一致、
|
|
||||||
`mm1_metrics` 的 `data` 该不该是 dict。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `models/metrics.py`:6 个回归指标落地(**由用户本人实现**)—— `rmse` / `mae` / `mape` / `smape` /
|
|
||||||
`r2_score` / `adjusted_r2`,共用一个入口守卫 `verify_yt_yp`(`as_array` → 形状 / 空 / NaN 三道校验)。
|
|
||||||
`compare_models` 仍是 `TODO(L1)`。
|
|
||||||
- `tests/test_models.py`:5 条 → **23 条**。`test_metrics_known_values` / `test_r2_is_one_for_perfect_fit`
|
|
||||||
两条摘掉 `skip` 转正,另新增 18 条 —— 形状不一致 / 空 / NaN / `str` 被 `TypeError` 挡、四种入参
|
|
||||||
(`DataFrame` / `Series` / `ndarray` / `list`)结果一致 ×5、`mape` 遇 0、`smape` 全 0 且**不许漏告警**、
|
|
||||||
`adjusted_r2` 的 `bool` / `float` / `0` / 分母保护 / `np.int64` / 惩罚单调性,以及 `r2_score` 的
|
|
||||||
**非完美拟合**解析值与 `sklearn.metrics.r2_score` 对照。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `models/metrics.py`:`r2_score` 的 `SS_tot` 基准由 `np.mean(y_pred)` 改为 `np.mean(y_true)`(**真 bug**,
|
|
||||||
由用户亲手改定,见 STATUS 决策 59 / 个人易错点 31),`ss_tol` 随之改名 `ss_tot`;修前 0.8803827751196172、
|
|
||||||
修后 0.875(与 sklearn 一致),`adjusted_r2` 同步修正。
|
|
||||||
- `models/metrics.py`:`smape` 的零除改用 `np.divide(..., out=..., where=denom != 0.0)` ——
|
|
||||||
`np.where` 的两条分支都会被求值,原写法在 `filterwarnings = ["error::RuntimeWarning"]` 下会让
|
|
||||||
"含 0 的 smape"测试直接失败(决策 60)。数值结果与口径不变。
|
|
||||||
- `models/metrics.py`:补 7 处 docstring(`Parameters` / `Returns` / `Raises`),把"NaN 报错 / `mape` 遇 0 报错 /
|
|
||||||
`r2_score` 常量 y 报错 / `adjusted_r2` 的分母保护"写成契约;`n_features` 的口径写明**不含截距**。
|
|
||||||
- `models/metrics.py`:报错文案统一为"参数名 + 实际值"(形状不一致会带上两个 `shape`);
|
|
||||||
ruff 9 条清零(6 条 `F541` 无占位符 f-string + 3 条 `W293`)+ 补函数间空行。
|
|
||||||
- `STATUS.md`:新增**决策 59 / 60**,「下一步」推进到第九轮(含 4 条待拍板口径);
|
|
||||||
验证计数更新为 **224 passed / 26 skipped(250 收集)**。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:新增易错点 31(公式的"基准量"抄错对象,且完美拟合会掩盖它)、
|
|
||||||
32(`np.where` 掩码防不住零除)。
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- **4 条口径待用户拍板**(Codex 只报不改):`mape` 遇负 `y_true` 现在返回负数、只拦 `NaN` 不拦 `inf`、
|
|
||||||
`rmse(1.0, 1.0)` 这类标量被当成 1 个样本、`compare_models` 的键名 `"r2"` 与函数名 `r2_score` 不一致。
|
|
||||||
详见 `STATUS.md`「第九轮新增:4 条口径待你拍板」。
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `models.compare_models`:落地(**Codex 按 A 档实现**,决策 62)。返回 `pd.DataFrame`(行 = 模型、列 = 指标),
|
|
||||||
按第一个指标排序(误差类升序、R² 降序)。`cv=0` 全量拟合后打分;`cv >= 2` 用**手写 K 折**
|
|
||||||
(`get_rng(random_state).permutation` + `np.array_split`,**不引入 sklearn 依赖**)报折间均值。
|
|
||||||
- `models/metrics.py` 的**指标名别名映射** `_METRIC_FUNCS`:`"r2"` 与 `"r2_score"` 指向同一个函数,
|
|
||||||
传哪个名字列名就叫哪个;两个一起传报 `ValueError`。`adjusted_r2` 不进表(需要 `n_features`)。
|
|
||||||
- `tests/test_models.py`:23 → **31 条**。首列排序(升序 / 降序)、别名映射、坏指标名 / 坏 `models` / 坏 `cv`、
|
|
||||||
每折样本数下限、`random_state` 可复现;另新增 `Inf` 与"至少 2 个样本"两条入口测试。
|
|
||||||
原 `test_compare_models_returns_table_sorted_by_first_metric` 的**用法是错的**(把拟合结果当模型传),
|
|
||||||
已改写成用测试桩 `_MeanModel` / `_LinearModel` 并摘掉 `skip`。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `models/metrics.py`:用户的第三轮入口改动入库(决策 61)—— `verify_yt_yp` 增加 `Inf` 拦截、
|
|
||||||
增加"至少 2 个样本"(标量不再被当成 1 个样本);`mape` 由"只拦 0"改成拦 `y_true <= 0`
|
|
||||||
(原式对负数返回**负数**,实测 `-1.0`)。
|
|
||||||
- `models/metrics.py`:三条新口径同步进 7 处 `Raises`,`mape` 的 `Returns` 与摘要改为"`y_true` 必须全为正数"
|
|
||||||
(**易错点 30 复发 1 次**:口径一改、docstring 立刻过期)。
|
|
||||||
- `models/metrics.py`:`compare_models` 的入口收口 —— `X` 必须二维(`as_array(ndim=2)` 不替你猜)、
|
|
||||||
`cv` 白名单(`bool` 拒收 / `cv=1` 无意义 / `cv <= n // 2`)、`models` 必须是"有 `fit` / `predict` 的对象"、
|
|
||||||
`metrics` 必须是 `tuple` / `list`(单个字符串给专门的报错文案)。
|
|
||||||
- `models/metrics.py`:模块 docstring 改为"指标函数只返回 `float`,唯一例外是 `compare_models`(返回 `DataFrame`)"。
|
|
||||||
- `STATUS.md`:新增**决策 61 / 62**,第九轮那 4 条口径标记办结,「下一步」推进到第十轮;
|
|
||||||
验证计数更新为 **233 passed / 25 skipped(258 收集)**。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:易错点 30 记为**复发 1 次**;新增易错点 33
|
|
||||||
(多行报错文案少引号 / `raise ValueError("a", "b")` 多参数 —— ruff 一秒能抓,别等运行时)。
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- **1 条小口径待用户拍板**:`verify_yt_yp` 里 `yt.ndim == 0` 是死代码(`as_array(ndim=1)` 必然摊平),
|
|
||||||
可塌成 `if yt.shape[0] <= 1:`。详见 `STATUS.md`「第十轮新出现的 1 条小口径」。
|
|
||||||
- **等 `fit_linear` 落地时补一条集成测试**:`compare_models` + 真实模型对象。
|
|
||||||
|
|
||||||
## [0.0.1] - 2026-10-01
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- 项目初始化,包结构落盘(实现为空)。
|
|
||||||
@@ -1,21 +1,18 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 孤竹居士
|
Copyright (c) 2026 guzhujushi
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
in the Software without restriction, including without limitation the rights
|
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
following conditions:
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||||
copies or substantial portions of the Software.
|
portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,72 +1,3 @@
|
|||||||
# MCM/ICM Toolkit
|
# MCM-ICM_Toolkit
|
||||||
|
|
||||||
> 这是我参加美赛时用的学习和准备复用的代码,有需要可以自行克隆和修改。
|
这是我参加美赛时用的学习和准备复用的代码,有需要可以自行克隆和修改
|
||||||
|
|
||||||
美赛(MCM/ICM)编程手的**竞赛层工具箱**:
|
|
||||||
把比赛中反复手写的代码沉淀成"高频算法自己实现 + 成熟库薄封装 + 统一结果接口"。
|
|
||||||
|
|
||||||
> **不**替代 SciPy / scikit-learn / statsmodels / NetworkX / SALib / SymPy。
|
|
||||||
> 只做它们到"4 天赛程能用"之间的那一层。
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 1) 建环境(两条路选一条)
|
|
||||||
.\scripts\setup-venv.ps1 # venv,零下载,复用 Anaconda base 的科学栈
|
|
||||||
.\scripts\setup-conda.ps1 # conda 环境 mcm-toolkit(精准包列表,走代理下载)
|
|
||||||
|
|
||||||
# 2) 自检
|
|
||||||
.\scripts\doctor.ps1
|
|
||||||
|
|
||||||
# 3) 激活
|
|
||||||
.\.venv\Scripts\Activate.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
import mcm_toolkit as mcm
|
|
||||||
|
|
||||||
mcm.set_seed(42)
|
|
||||||
r = mcm.bootstrap_ci(data, statistic=np.median, random_state=42)
|
|
||||||
print(r.params["low"], r.params["high"])
|
|
||||||
```
|
|
||||||
|
|
||||||
## 当前状态(v0.0.1)
|
|
||||||
|
|
||||||
本地 git 仓库已初始化(`main`,首次提交 `5319589`),远程 Gitea 待配置。
|
|
||||||
|
|
||||||
**接口已定,实现未填。** 所有函数体是 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记,
|
|
||||||
按 [docs/03-roadmap.md](docs/03-roadmap.md) 的 L0 → L1 顺序填。
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
| 路径 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `src/mcm_toolkit/` | 包源码,7 个子包 |
|
|
||||||
| `tests/` | 测试,与包一一对应(测已知解析解,不测实现细节) |
|
|
||||||
| `examples/` | 每个模块一个能跑的最小例子 |
|
|
||||||
| `problems/` | 历年真题(每做完一题,反向沉淀 1~3 个通用函数) |
|
|
||||||
| `notebooks/` | 探索用 |
|
|
||||||
| `docs/` | 需求、架构、路线图、环境、规范 |
|
|
||||||
| `scripts/` | 环境脚本 |
|
|
||||||
| `STATUS.md` | 当前进度 |
|
|
||||||
|
|
||||||
## 子包
|
|
||||||
|
|
||||||
| 包 | 职责 | 关键函数 |
|
|
||||||
|---|---|---|
|
|
||||||
| `core` | 基础设施 | `Result` `get_rng` `check_*` |
|
|
||||||
| `data` | 读 / 洗 / 变换 / 采样 | `load_table` `detect_outliers` `normalize` |
|
|
||||||
| `stats` | 描述 / 推断 / bootstrap | `describe` `bootstrap_ci` `fit_distribution` |
|
|
||||||
| `models` | 回归 / 时序 / ML | `fit_linear` `fit_arima` `compare_models` |
|
|
||||||
| `solve` | 优化 / 模拟 / 敏感性 | `monte_carlo` `simulated_annealing` `sobol_analyze` |
|
|
||||||
| `decision` | 评价决策 | `entropy_weights` `ahp_weights` `topsis` `gm11` |
|
|
||||||
| `viz` | 出图出表 | `plot` `save_figure` `save_table` |
|
|
||||||
|
|
||||||
## 文档
|
|
||||||
|
|
||||||
开始动手前请先读 [docs/01-suggestion-review.md](docs/01-suggestion-review.md)——
|
|
||||||
它记录了"为什么最终结构是这样",以及**明确不做什么**。
|
|
||||||
|
|
||||||
## 许可
|
|
||||||
|
|
||||||
MIT,见 [LICENSE](LICENSE)。
|
|
||||||
@@ -1,914 +0,0 @@
|
|||||||
# STATUS — 当前进度
|
|
||||||
|
|
||||||
> 更新日期:2026-10-18
|
|
||||||
|
|
||||||
## 本次任务
|
|
||||||
|
|
||||||
**第一阶段(已完成)**:审查并精简 ChatGPT 的建库建议 → 生成骨架文件 →
|
|
||||||
配置 conda 与 venv 环境 → 初始化 git 仓库 → 接入 Gitea 远端。
|
|
||||||
两套环境均已建成并通过验证,仓库已推送到 `code.guzhujushi.cn`。
|
|
||||||
|
|
||||||
**第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**。
|
|
||||||
|
|
||||||
**分工硬规矩(决策 47,用户 2026-10-03 明确)**:
|
|
||||||
- **算法一律由用户本人写**(S 档尤其)—— 即便用户说"你写吧"也不代写,
|
|
||||||
要保住"一边学一边自己写"的路径。
|
|
||||||
- **工程事务由 Codex 写、用户读**:入口/校验重构、`tests/`、docstring、ruff、
|
|
||||||
`STATUS.md` / `CHANGELOG.md`、函数卡片、`.gitignore` 等。
|
|
||||||
- A 档**薄封装**(`rmse`/`mae`/`fit_linear`/`train_test_split`…)算工程,Codex 可直接实现。
|
|
||||||
- 分地盘按**文件**划,不按函数划(同一文件两人各写一半曾导致用户编辑器保存失败)。
|
|
||||||
|
|
||||||
当前 **L0 12/12(已全部完成)**:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
|
|
||||||
`detect_outliers`、`normalize`/`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`、
|
|
||||||
`monte_carlo`(**用户写**,2026-10-04);另**提前落地**了 L1 的 `confidence_interval` / `t_test` /
|
|
||||||
`monte_carlo_probability`、`monte_carlo_integral`、`random_walk`、`markov_simulate`、
|
|
||||||
`markov_steady_state`、`mm1_metrics`(**均为用户写**)—— `solve` 层的 L1 清单**已全部落地**。
|
|
||||||
`save_figure` 已于 2026-10-18 由 Codex 落地,L0 收官。
|
|
||||||
**`models/metrics.py` 已完成(第九~十轮)**:6 个指标(`rmse` / `mae` / `mape` / `smape` / `r2_score` /
|
|
||||||
`adjusted_r2`)+ 共享入口 `verify_yt_yp` **由用户写**、Codex 审;`compare_models` 由 Codex 按 A 档落地
|
|
||||||
(含指标名**别名映射**)。`models` 下一步是 `regression.py`。
|
|
||||||
`monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**;
|
|
||||||
`markov_simulate`(决策 56)/ `markov_steady_state`(决策 57)/ `mm1_metrics`(决策 58)
|
|
||||||
的入口口径**已办掉大半**,各剩 1~2 条细枝末节(详见"下一步"第八轮)。
|
|
||||||
另 3 个小口径仍悬着:`ci_low`/`ci_high` 的语义、`monte_carlo_integral` 的第一道 `isinstance` 分支可塌成两行、
|
|
||||||
`random_state` 注解与 `std` 的 `ddof` 是否全项目对齐。
|
|
||||||
|
|
||||||
## 任务清单
|
|
||||||
|
|
||||||
### ✅ 已完成
|
|
||||||
|
|
||||||
**A. 建议审查与精简**
|
|
||||||
- [x] 通读 `chatGPT_Suggestion.md`(33 节,约 2.4 万字),输出 `docs/01-suggestion-review.md`
|
|
||||||
- 总裁决:**方向对,量级错**
|
|
||||||
- 14 个包 → **7 个**;约 250 个函数 → **约 40 个**;5 个 Result 类 → **1 个**
|
|
||||||
- 单函数文档要求 10 项 → **2 档**(只有约 15 个招牌算法写完整卡片)
|
|
||||||
- 逐项给出"砍什么 / 为什么 / 替代什么",并补上原文缺失的六件事
|
|
||||||
(论文产出链路、指标方向与量纲、时间预算、可复现性、测试测什么、离线约束)
|
|
||||||
|
|
||||||
**B. 骨架文件生成**(76 个文件,约 6800 行)
|
|
||||||
- [x] `docs/02-architecture.md` 架构、`docs/03-roadmap.md` 路线图与**永久不做清单**
|
|
||||||
- [x] `docs/04-environment.md` 环境文档、`docs/dev/function-card-template.md` 卡片模板
|
|
||||||
- [x] `src/mcm_toolkit/` 7 个包 23 个模块(**只有签名 + docstring + `NotImplementedError`**)
|
|
||||||
- [x] `tests/` 9 个文件、`examples/` 4 个、`problems/` `notebooks/` `figures/` `tables/`
|
|
||||||
- [x] `pyproject.toml`(核心依赖 4 个 + 6 组 extras)、`environment.yml`
|
|
||||||
- [x] `.gitignore` `.gitattributes` `.editorconfig` `LICENSE` `CHANGELOG.md` `README.md`
|
|
||||||
- [x] 脚本 `scripts/setup-venv.ps1` `setup-conda.ps1` `doctor.ps1`
|
|
||||||
|
|
||||||
**C. 环境配置**
|
|
||||||
- [x] **venv 已建好**:`.venv`(10.5 MB,Anaconda base + `--system-site-packages`)
|
|
||||||
+ `pip install -e . --no-deps --no-build-isolation`;
|
|
||||||
可直接导入 numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6 / matplotlib 3.11.2 /
|
|
||||||
sklearn 1.9.1 / statsmodels 0.15.0 / networkx 3.6.1 / sympy 1.14.0
|
|
||||||
(版本随 base 走,base 升级后自动同步)
|
|
||||||
- [x] **conda 环境已建好**:`D:\anaconda\envs\mcm-toolkit`(1.55 GB,Python 3.13.15)
|
|
||||||
- 科学栈:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6 / matplotlib 3.11.2 /
|
|
||||||
sklearn 1.9.1 / statsmodels 0.15.0 / networkx 3.6.1 / sympy 1.14.0(MKL 后端)
|
|
||||||
- 已补装 pip-only 依赖:SALib、pymoo 0.6.2、pyright 1.1.414
|
|
||||||
- 已在本项目中可编辑安装
|
|
||||||
- 实测耗时 868 秒,下载约 3 GB
|
|
||||||
- [x] **Anaconda base 已升级** 2025.12 → **2026.07**(365 个包,下载 638 MB,耗时 1033 秒)
|
|
||||||
- conda 自身 25.11.1 → **26.7.3**(实测复核)
|
|
||||||
- 并把项目环境 numpy 对齐到 base 的 2.4.6,**两套环境数值栈现已完全一致**(MKL 后端)
|
|
||||||
- 升级前已用 `conda list -n base --revisions` 留了回滚点
|
|
||||||
- [x] 环境脚本按实测结果重写:`setup-conda.ps1` 默认改为**精准包列表**
|
|
||||||
(`--clone base` 实测会走完整下载安装,不再是"离线快捷方式")
|
|
||||||
|
|
||||||
**D. Git 仓库**
|
|
||||||
- [x] `git init -b main`,`core.autocrlf=false`,首次提交
|
|
||||||
`5319589`(76 文件 / 6812 行),工作区干净
|
|
||||||
- [x] `git ls-files --eol` 复核:**全部 LF**,`.gitattributes` 生效
|
|
||||||
- [x] 仓库由**本人账户 guzhu** 创建(见决策 17)
|
|
||||||
- [x] **远端 Gitea 已接通并推送**:`origin` = `git@code.guzhujushi.cn:guzhujushi/MCM-ICM_Toolkit.git`,
|
|
||||||
`main` 跟踪 `origin/main`,远端 HEAD = `96080b9`,76 个文件已在服务器上(见决策 19/20)
|
|
||||||
- [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`),
|
|
||||||
`~/.ssh/config` 加了一条域名别名,remote 里仍写域名
|
|
||||||
|
|
||||||
**E. L0 实现进度(10/12)**
|
|
||||||
- [x] `core/result.py` 的 `Result`
|
|
||||||
- `__repr__`:单行紧凑;`data` 只给类型与规模,**绝不**整数组打印
|
|
||||||
(十万级数组的 repr 实测仍 < 200 字符);`success` 恒在
|
|
||||||
- `summary()`:Markdown 表格,可直接贴进论文;
|
|
||||||
行序 `success` → `params.*` → `metrics.*` → `extras.*`;
|
|
||||||
float 走 `.6g`,`|` 转义,`message` 以引用行附在表后;幂等无副作用
|
|
||||||
- 测试:`tests/test_core.py` 去掉 skip + 新增 3 条
|
|
||||||
(默认值/失败路径/大数组、Markdown 结构、可重复调用)
|
|
||||||
- [x] `core/random.py` 的 `get_rng` / `set_seed`(**设计由用户本人写**,Codex 补齐类型校验)
|
|
||||||
- 无参 `get_rng()` 复用**同一个模块级生成器**且会推进:既保证重跑一致,
|
|
||||||
又避免两个都省略 `random_state` 的函数抽到同一串数(原决策 24 的取舍已消掉)
|
|
||||||
- `get_rng` 收 `int` / `np.integer` / `Generator` / `None`,其余类型**抛 `TypeError`**:
|
|
||||||
`np.int64` 不是 `int` 的子类,曾因此静默退回全局流
|
|
||||||
- `set_seed` 同时调 `np.random.seed`,覆盖 sklearn / statsmodels 走的 numpy 旧全局
|
|
||||||
(全项目**唯一**碰旧接口的地方,带 `# noqa: NPY002` 与理由)
|
|
||||||
- 测试:`TestRandom` 共 7 条(可复现、透传、无参跟随、旧全局、np.integer、非法类型、共享推进)
|
|
||||||
- [x] `data/io.py` 的 `load_table`(+ `save_table`,原属 L1,顺手落地)
|
|
||||||
- 实现由**用户本人**写(决策 25),Codex 只做审查 + 补测试
|
|
||||||
- `.csv` / `.xlsx` / `.xls` 按扩展名分派;`.csv` 默认编码探测
|
|
||||||
utf-8 → utf-8-sig → gbk → gb18030,显式传 `encoding=` 时不再探测
|
|
||||||
- `na_values` 默认吃下空串 / `NA` / `N/A` / `#N/A` / `null` / `-`
|
|
||||||
- 不支持的扩展名抛 `ValueError`;文件不存在抛带路径的 `FileNotFoundError`
|
|
||||||
- `save_table` 的校验顺序已修正:后缀判断提到 `mkdir` 之前
|
|
||||||
(此前传 `.txt` 会先留下一个空目录再抛错)
|
|
||||||
- 测试:`tests/test_data.py` 由 1 条(且被模块级 skip 掉)扩到 **9 条**
|
|
||||||
- docstring 的 `Parameters` / `Raises` 已按 §4.7 补齐(Codex 代写,用户授权)——
|
|
||||||
numpydoc 格式,对齐参考实现 `core/random.py` 的 `get_rng`
|
|
||||||
- [x] `data/clean.py` 的 `clean_missing`(L0 第 4 项)
|
|
||||||
- 实现由**用户本人**写,Codex 只做审查、补测试与文档
|
|
||||||
- `threshold` 用 `math.ceil` 算 `min_non_na`:3 列、阈值 0.5 时必须是 2,
|
|
||||||
写 `int()` 截断会漏删
|
|
||||||
- 顺手落地兄弟函数 `impute_missing` / `winsorize`(原属 L1)
|
|
||||||
- [x] `data/clean.py` 的 `detect_outliers`(L0 第 5 项,S 档,**实现由用户本人写**)
|
|
||||||
- 语义由用户本人定:**非数值列不参与统计,掩码里恒 `False`**(见决策 32)
|
|
||||||
- 三轮修掉的 4 个 bug:`threshold` 范围校验放错函数、IQR 上界符号、
|
|
||||||
`is_series` / `is_ld_array` 判在类型转换之后(永远为 False)、文本列上算分位数直接崩
|
|
||||||
- 关键教训:**限制掩码 ≠ 限制统计量** —— 只把 `mask[numeric_cols] = ...`
|
|
||||||
收窄是不够的,`quantile()` / `mean()` / `std()` 也要一起收窄(见坑 8 / 个人易错点 8)
|
|
||||||
- 返回形状与输入同形;Series 进 Series 出(列名保留)、一维数组进一维布尔数组出
|
|
||||||
- **S 档函数卡片已写**:`docs/algorithms/detect_outliers.md`
|
|
||||||
(含"为什么不返回 `Result`"、非数值列恒 `False` 的两条备选路、`ddof` 口径不一致等)
|
|
||||||
- 测试:`tests/test_data.py` 该函数共 **9 条**
|
|
||||||
- [x] `data/clean.py` 的两处收尾(**Codex 代写,用户授权**)
|
|
||||||
- `impute_missing` 的 mode 分支改为**返回新表**(原先就地改调用方的表,与另外四支不一致)
|
|
||||||
—— 最后一条 `xfail` 随之转正,**`xfail` 清零**
|
|
||||||
- `detect_outliers` 补**入参类型校验**(§4.7 要求非法类型抛 `TypeError`):
|
|
||||||
接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`,`dict` 不再接受(决策 33)
|
|
||||||
- [x] `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` /
|
|
||||||
`impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测可直接跑通
|
|
||||||
- [x] `data/transform.py` 的四个函数(**由用户本人实现**):`normalize` / `standardize`(L0 第 6、7 项)、
|
|
||||||
`log_transform` / `one_hot_encode`(原属 L1,顺手写了)
|
|
||||||
- `normalize`:三种 method(`minmax` / `vector` / `max`)都**按列**;`feature_range` 校验下界 < 上界;
|
|
||||||
常量列压到下界而不出 nan(决策 34)
|
|
||||||
- `standardize`:按列,`ddof` 真的传下去 —— 测试用解析关系钉住
|
|
||||||
(`ddof=0` → std 恰为 1;`ddof=1` → `sqrt((n-1)/n)` = 0.866)
|
|
||||||
- `log_transform`:`offset=None` 时自动取 `1 - min(x)`,保证 `x + offset >= 1`
|
|
||||||
- `one_hot_encode`:自动识别的 dtype 集合已显式补上 `'str'` / `'string'`(见坑 9)
|
|
||||||
- Codex 清掉 5 条 ruff(RET503 / F541 / RET504 / RET505 / SIM108),并把 `feature_range`
|
|
||||||
的两条校验提到 `np.asarray` **之前**、修掉说反的报错文案(条件是"下界必须小于上界")
|
|
||||||
- 测试:**新写 18 条**,其中两条是把原先挂着的 `skip` 转为实跑 —— 见 `tests/test_data.py` 的 transform 段
|
|
||||||
- [x] **`transform.py` 的 4 个口径已收口**(2026-10-02,Codex 代修,用户授权"报错顺手修掉"):
|
|
||||||
- 输入白名单统一成模块级 `_NUMERIC_TYPES`,**`DataFrame` 补进去**并移到 `np.asarray` 之前
|
|
||||||
(此前 `DataFrame` 不在白名单 → `standardize(df)` / `log_transform(df)` 直接 `TypeError`,
|
|
||||||
函数体里所有 `is_df` 分支都是**死代码**)
|
|
||||||
- 三个函数都保持"DataFrame 进 DataFrame 出"(列名 + 索引原样带回),Series 进 Series 出
|
|
||||||
- `normalize` 的三个 method 分支原来**互相覆盖**(`minmax` / `vector` 算完的结果被末尾
|
|
||||||
无条件的 `x / max` 冲掉,三种 method 实际都只走 max),改成 `if / elif / else`
|
|
||||||
- `one_hot_encode` 不再拿 `data.columns` 重包一层(哑变量列名对不上 → 编码列整列丢失)
|
|
||||||
- `log_transform` 的 `offset` 守卫**写反了**(`offset > 0` 就抛错,恰好拒掉唯一合法用例),
|
|
||||||
改成校验真正的定义域前提 `min(x) + offset > 0`(见决策 35)
|
|
||||||
- docstring 按 §4.7 补齐 `Parameters` / `Returns` / `Raises`(Codex 代写,与 `io.py` 同一套 numpydoc 格式)
|
|
||||||
- 口径定稿后 ruff 4 条 RET505 一并清掉 → `All checks passed`;pyright **2 errors → 0**
|
|
||||||
- [x] `stats/descriptive.py` 的 `describe`(L0 第 8 项,**由用户本人实现**,Codex 审查 + 代修)
|
|
||||||
- **一个静默 bug(最危险的那种)**:`df_num.describe().T` 把表转反了(行 = 变量、列 = 指标),
|
|
||||||
而 docstring 与 `test_describe_reports_expected_keys` 都要求"行 = 指标、列 = 变量"。
|
|
||||||
转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名**零交集**,pandas 不报错,
|
|
||||||
直接把这四行(`missing` / `missing_ratio` / `skewness` / `kurtosis`)**整行填成 NaN**。
|
|
||||||
去掉 `.T` 一处即修好三件事:方向对了、NaN 没了、`weights` 分支不再报
|
|
||||||
`cannot set a row with mismatched columns`(见易错点 11、决策 36)
|
|
||||||
- `median` 行补上(pandas 的 `describe()` 只有 `50%`,而测试契约定的是 `median`)
|
|
||||||
- 入参放宽到 `list` / `tuple` / `ndarray`(兄弟函数都收,`00_quickstart.py` 传的就是 ndarray)
|
|
||||||
- `weights` 校验补齐:长度、非一维、NaN/inf、负值、和为 0 都抛带原因的错误
|
|
||||||
(原先和为 0 时裸抛 `ZeroDivisionError`)
|
|
||||||
- `zscore`:修 RET505,docstring 补 `Parameters` / `Returns` / `Raises`;
|
|
||||||
`coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError`(那是取值问题不是类型问题)、
|
|
||||||
空数组提前报错(原先静默返回 nan + RuntimeWarning)、改掉"异变系数"错别字;分母改用 `|mean|`(**用户本人定**,见决策 38)
|
|
||||||
- pyright 的 `DataFrame | Series` 重载问题按坑 8 用 `cast` 收窄;ruff 7 条清到 0
|
|
||||||
- 测试:`test_stats.py` 去掉**模块级** `pytestmark`(它把 6 条测试**全部**跳过了 ——
|
|
||||||
决策 26 说的就是这个,实现写完了却零覆盖),改成逐条 skip 未实现的 5 条,
|
|
||||||
并新写 20 条 describe / zscore / 变异系数用例
|
|
||||||
- [x] `examples/01_clean_and_describe.py`:填掉末尾的 `TODO(L0)`,加一段 `describe` 输出
|
|
||||||
- [x] `stats/inference.py` 的 `correlation_matrix`(L0 第 9 项,**由用户本人实现**,Codex 审查 + 代修)
|
|
||||||
- **硬 bug**:`if numeric_cols == 0:` 拿 `Index` 和标量比,得到的是布尔**数组** ——
|
|
||||||
0 列时静默不报错、1 列时碰巧能跑、**≥2 列直接 `ValueError` 崩**;
|
|
||||||
而"≥2 个数值列"正是最常见的情况,等于整个函数不可用。改成 `len(numeric_cols) == 0`(易错点 13)
|
|
||||||
- pyright 的报错也都不是运行时问题:`data[numeric_cols]` 是坑 8 的 `DataFrame | Series` 重载
|
|
||||||
(`cast` 收窄);`_, p = stats.pearsonr(...)` 解包会退化成 `object` —— scipy 的返回值是
|
|
||||||
`_make_tuple_bunch` 运行时拼的类、存根里看不到 `pvalue`(见 `docs/04-environment.md` 坑 10)。
|
|
||||||
加了 `_pvalue(result: Any)` 小助手统一取 p 值,**一处都没挂 `# type: ignore`**
|
|
||||||
- `method` 校验提到最前、清掉 3 条 F541 / 1 条 RET505 / I001,ruff 归零
|
|
||||||
- **现在返回**:`correlation_matrix` → 数值系数矩阵(对角线 1.0);
|
|
||||||
`correlation_pvalues` → 数值 p 值矩阵(**对角线 NaN**);
|
|
||||||
`correlation_table` → 星标字符串表(**对角线 `1.000` 不带星**)。
|
|
||||||
- **返回契约冲突(已解决)**:上一版返回的是**带星标的字符串表**(`1.000***`),
|
|
||||||
而 docstring 契约与 L0 用例都要求**数值矩阵**(对角 = 1.0);按方案 A 拆成三个函数(见下)
|
|
||||||
- [x] `correlation_matrix` 的**返回契约**按**方案 A** 落地(2026-10-03,用户拍板):
|
|
||||||
`correlation_matrix` 只返回**数值**矩阵;星标表拆成 `correlation_table`、p 值拆成
|
|
||||||
`correlation_pvalues`(三者共用 `_check_corr_args` 守卫与**成对删缺失**口径,见决策 41)。
|
|
||||||
顺带修掉两处:**对角线不再带 `***`**(p 值矩阵的对角线是 NaN、"自己和自己"没有显著性);
|
|
||||||
**p 值与 r 用同一批观测**(都成对删缺失、都尊重 `min_periods`),不再各算各的样本量。
|
|
||||||
p 值只算上三角再镜像,省一半重复计算。测试 **+8 条**(含用解析式 `t = r·sqrt((n-2)/(1-r²))` 对照)
|
|
||||||
- [x] `stats/inference.py` 的 `confidence_interval` / `t_test`(**L1 提前落地,实现由用户本人写**,Codex 审查 + 代修)
|
|
||||||
- **用户问的「307/308 两行报错」不是真错**:那两行是 `float(res.statistic)` / `float(res.pvalue)`,
|
|
||||||
报错来自 scipy 的 `TtestResult` —— 它和 `PearsonRResult` 一样是 `_make_tuple_bunch` **运行时**拼的类,
|
|
||||||
存根里没有这两个字段。探针实测 `TtestResult(statistic=3.873, pvalue=0.0305)`,运行时取值完全正常。
|
|
||||||
按决策 40 的路子补了一个 `_statistic(result: Any) -> float` 助手(与 `_pvalue` 成对),
|
|
||||||
**一行 `# type: ignore` 都没挂**。详见 `docs/04-environment.md` 坑 10
|
|
||||||
- **`confidence_interval` 有真 bug(已修)**:原来返回 `(float(min(0.0, lower)), float(max(upper, 1.0)))` ——
|
|
||||||
`min` / `max` 的**参数写反**(下界被钉死成 `0.0`),而且**无条件**把所有 method 的区间夹到 `[0, 1]`。
|
|
||||||
实测 `confidence_interval(rng.normal(170, 8, 50), method="t")` 的下界是 `0.0`(正确是 `171.0 ± 2.1`);
|
|
||||||
`confidence=1.5` 更是被夹成 `(0.0, nan)` 而不报错。现在**只有 `proportion` 夹**(决策 42),
|
|
||||||
`normal` / `t` 直接返回 `mean ± z|t·se`;`method` 非法改抛 `ValueError`(§4.7)、
|
|
||||||
`confidence` 按 `(0, 1)` **开区间**校验、样本量 < 2 抛 `ValueError`
|
|
||||||
- `confidence_interval` 的入参放宽到 `list` / `tuple` / `ndarray` / `Series` / `DataFrame`:
|
|
||||||
docstring 的 Examples 传 list、L0 用例传 ndarray,原来**两条都直接 `TypeError`**
|
|
||||||
- `t_test`:`_clean_data` 改用 `isinstance(data, pd.DataFrame)` 收窄(坑 8 的 `cast`)、`alpha` 开区间校验、
|
|
||||||
`df` 按 Welch–Satterthwaite / pooled / paired 分别给,docstring 补齐 `Parameters` / `Returns` / `Raises` / `Notes`(§4.7)
|
|
||||||
- 测试 `tests/test_stats.py`:**+10 条(36 → 46)**,含把原先挂着 skip 的 `test_confidence_interval_matches_scipy` 转正;
|
|
||||||
对照一律用解析式(z 公式、Welch `df` 公式、`t = (mean − value)/(s/√n)`),不拿实现对自己
|
|
||||||
- ⚠️ **`t_test` 还有 3 个口径待用户拍板**(Codex 未改,见「下一步」第 6 条)
|
|
||||||
- [x] **`core/validate.py` 的 `as_array`**(统一入口,2026-10-03 落地)
|
|
||||||
- 用户提出"每个函数都抄一遍入口/校验/交换太麻烦"。结论:**入口能统一,出口不能一刀切** ——
|
|
||||||
还原要求"输出与输入同形",而 `describe` / `correlation_*` / `confidence_interval` / `t_test`
|
|
||||||
的输出形状天生和输入不同,硬套只会重演易错点 14。
|
|
||||||
- 契约:`as_array(x, *, numeric_only=True, dtype=float, ndim=None) -> (arr, kind)`,
|
|
||||||
`kind` 是 `"frame"` / `"series"` / `"array"`(**只报告原类型,还原由调用方手写**,用户拍的板)。
|
|
||||||
- 覆盖掉重复:原先 `select_dtypes` / `isinstance(..., pd.DataFrame|Series)` 散在 **5 个文件 31 处**;
|
|
||||||
现在 `normalize` / `standardize` / `log_transform` / `zscore` / `confidence_interval` / `t_test`
|
|
||||||
各只剩 1 行入口。`data/transform.py` 的 `_NUMERIC_TYPES` 白名单连同 3 份副本一起删掉。
|
|
||||||
- 两条硬规矩:**默认绝不摊平**(要摊平显式 `ndim=1`);**不删缺失值**(删缺失是各函数的语义,
|
|
||||||
见决策 45 与易错点 15)。`numeric_only` 按"输出是否与输入同形"选,不按喜好选(决策 44)。
|
|
||||||
- `check_array`(L1,未实现)实现时直接复用 `as_array`,一份实现两张脸
|
|
||||||
- [x] `stats/bootstrap.py` 的 `bootstrap_ci`(L0 第 10 项,**S 档**,实现由用户本人写,Codex 审查 + 代修)
|
|
||||||
- 移植正确性已用**解析解**钉住:大样本正态下百分位区间半宽 ≈ `2 × 1.96 × se`(容差 10%)
|
|
||||||
- **Codex 代修的三处**:
|
|
||||||
① `random_state=None` 时原来另开了 `np.random.default_rng()`,**绕过了 `get_rng`** ——
|
|
||||||
与决策 24 冲突(`set_seed` 后仍然不可复现)。改成无条件的 `get_rng(random_state)`;
|
|
||||||
② `statistic_name` 写成 `hasattr(statistic, "__name__")`,而 lambda 的 `__name__` 恒为 `"<lambda>"`,
|
|
||||||
导致 `else` 分支是**死代码**、自定义统计量拿不到 `"custom"`(见易错点 17);
|
|
||||||
③ ruff 两条(I001 导入块、F541 空 f-string),并把 docstring 的 `Parameters` / `Returns` / `Raises` 补齐(§4.7)
|
|
||||||
- **测试是重点**:用户实现写完后,`tests/test_stats.py` 里等它的 3 条**仍然挂着 `skip`**,
|
|
||||||
全量照样显示"全绿" —— 正是易错点 9 的复发(已把该条 `复发` 记到 1)。
|
|
||||||
已摘掉 3 条 skip,并新增 6 条:`estimate` 等于原样本统计量、`confidence` 单调性、
|
|
||||||
五种容器一致、自定义 lambda 走回退分支、**不给种子时跟随 `set_seed`**、非法 `method` 抛错。
|
|
||||||
- **S 档函数卡片已写**:`docs/algorithms/bootstrap_ci.md`(第 2 张),
|
|
||||||
记了"极差统计量的区间上界必然塌到 `estimate`"这个非平凡性质,以及 5 个待拍板口径
|
|
||||||
- 测试:`tests/test_stats.py` 的 bootstrap 段共 **9 条**
|
|
||||||
- [x] **工程收尾(本轮)**:`tests/test_solve.py` 的模块级 `pytestmark = pytest.mark.skip(...)`
|
|
||||||
拆成 **9 条逐条** `@pytest.mark.skip(reason=_NOT_IMPLEMENTED)` —— 模块级 skip 会把整个文件吞掉,
|
|
||||||
正是易错点 9 的根源(决策 26)。收集数不变(177)、跳过数不变(34),只是粒度变细。
|
|
||||||
- [x] `solve/simulate.py` 的 `monte_carlo`(**L0 第 11 项,S 档,算法由用户本人写**,2026-10-04)
|
|
||||||
- 核心逻辑:向量化采样 → 一次仿真 → 8 个指标;`get_rng` 无条件调用(决策 46 已内化)
|
|
||||||
- 用户第二轮改了三处(见决策 48):批量大小改由实现传 `sampler(rng, size=n)`;
|
|
||||||
结果含 NaN 判失败;`np.std(data, ddof=0)` 写明
|
|
||||||
- **Codex 只碰工程侧,算法一行未动**:
|
|
||||||
① ruff 三条(I001 合并同源 import、F541 空 f-string ×2)
|
|
||||||
② docstring:补 `Parameters`(原文漏 `random_state` / `**kwargs`)与 `Raises`;按新契约改写
|
|
||||||
`sampler` 说明(必须能接住 `size`,且 `**kwargs` 里别再传 `size`);`Notes` 记 NaN 口径;
|
|
||||||
**修掉跑不通的 Examples**(原版 `lambda rng, **k: rng.normal(0, 1)` 返回标量,实测 `success=False`)
|
|
||||||
③ 按 §4.7 收严 `n` 校验:`np.int64` 不再被拒、`n=True` 改抛 `TypeError`、浮点 `n` 归 `TypeError`、
|
|
||||||
`n<=0` 归 `ValueError`
|
|
||||||
④ 格式与文案:`success= True` → `success=True`;失败文案"重采样" → "模拟"(从 `bootstrap_ci` 抄来没改)
|
|
||||||
⑤ **入口统一 `dtype=float`** —— 修掉一个真 bug:布尔结果("事件是否发生",MC 最常见的用法)
|
|
||||||
会让 `np.std` 在 numpy 2 里抛 `boolean subtract ... is not supported`,整个函数判失败;
|
|
||||||
字符串结果也从"ufunc isnan 不支持"变成可读的 `could not convert string to float`
|
|
||||||
- **测试:10 条**(`tests/test_solve.py`)—— 返回长度与 8 个 key、`simulator` 只被调用一次(向量化的可执行版本)、
|
|
||||||
同种子可复现、省略种子时跟随 `set_seed`、E[X²]=1(n=2e5)、docstring Example 真能跑、`np.int64` 的 n、
|
|
||||||
布尔结果按 0/1 统计、NaN 结果判失败、5 个非法入参
|
|
||||||
- 行为探针实测:`simulator` 调用次数 = **1**(真向量化);20/20 次区间覆盖 0
|
|
||||||
- [x] `solve/simulate.py` 的 `monte_carlo_probability`(**L1,S 档,算法由用户本人写**,2026-10-05)
|
|
||||||
- 核心:向量化 `event(batch) -> bool 数组` → `p = mean(data)` → **Wilson score 区间**并夹到 `[0, 1]`
|
|
||||||
(比例量纲,沿用决策 42 的口径——同类问题没重犯)
|
|
||||||
- 用户第二轮(2026-10-05)按 6 条待办改掉 5 条:Wilson 替 Wald、`callable` 入口校验、
|
|
||||||
`confidence` 记进 `params`、`float()` 统一 ci 类型、小概率不再判失败(改成 message 里的警告)——见决策 50
|
|
||||||
- **Codex 第三轮只碰工程侧**:① `z_score^2` → `z_score**2` —— **真 bug**:`^` 是异或,
|
|
||||||
numpy 浮点直接抛 `TypeError`,被 `except` 吞成 `success=False`,6 条测试全红才暴露(易错点 21);
|
|
||||||
② message 无警告时的尾逗号;③ `{"confidence":confidence}` 的空格;④ docstring 的 "Wald" → "Wilson";
|
|
||||||
⑤ 新增 1 条对照测试:区间必须等于 `statsmodels.proportion_confint(method="wilson")`
|
|
||||||
- 行为探针实测:p≈0.5 正确(0.49991 @ n=2e5)、`confidence` 变大区间变宽、
|
|
||||||
同种子可复现、省略种子跟随 `set_seed`、`np.int64` 的 n 可收、`confidence=1.5` 抛 `ValueError`
|
|
||||||
- **Codex 只碰工程侧,算法一行未动**:
|
|
||||||
① ruff 六条(SIM300 Yoda、W291 ×2、W293 ×2、F541)
|
|
||||||
② docstring:补 `Parameters`(原文只有 Returns/Notes/Examples)与 `Raises`,
|
|
||||||
**修掉跑不通的 Examples**(原 `lambda rng, **k: rng.normal(0, 1)` 返回标量,实测 `success=False`),
|
|
||||||
并把"Wald 区间 + 夹到 [0,1]"与"`p <= 1e-3` 判失败"两条**现状**如实写进 Returns / Notes
|
|
||||||
③ 格式:`metrics` 字典缩进、`{"n":n, ...}` 空格、行尾空白
|
|
||||||
④ 文案:NaN 提示里的 "simulator" 改成 "event"(从 `monte_carlo` 抄来没改)
|
|
||||||
- **测试:摘掉 2 条 skip**(`test_monte_carlo_probability_of_standard_normal`、`test_monte_carlo_is_reproducible`
|
|
||||||
—— 实现写完还挂着 skip,正是易错点 9)并新增 5 条,`solve` 段共 24 条
|
|
||||||
- [x] `solve/simulate.py` 的 `monte_carlo_integral`(**L1,S 档,算法由用户本人写**,2026-10-05)
|
|
||||||
- 核心:`bounds` → 自己造 `(n, d)` 均匀样本 → 逐点求值 → `mean(func(X) × 体积)`;
|
|
||||||
自造 `sampler` / `simulator` 再复用 `monte_carlo` —— "造批 → 一次算 → 汇总"这套骨架的第二次复用
|
|
||||||
- **Codex 只改了一处真 bug(决策 31,纯 bug)**:体积写成 `wights = lows - highs`(名字打错 + 减反)。
|
|
||||||
奇数维体积为负:1D `x²` 于 `[0,1]` 实测 **-0.3324**(应 +1/3)、3D `x` 得 **-0.5005**(应 +1/2);
|
|
||||||
而 **2D `x·y` 得 +0.2499 "看着对"** —— 两个负号相乘抵消,把 bug 藏住了。
|
|
||||||
改成 `widths = highs - lows` 后复测 **0.3324 / 0.5005 / 1.9971(`[0,2]`)** 全部正确
|
|
||||||
- **Codex 只碰工程侧**:① docstring 补 `func` / `n` / `random_state` 三节 `Parameters` 与 `Raises`;
|
|
||||||
② `Returns` 写清"**`metrics["mean"]` 才是积分估计值**,`data` 是 `n` 个样本各自的贡献";
|
|
||||||
③ **修掉跑不通的 Examples**(原版断言 `abs(r.data - 1 / 3) < 0.01` —— `data` 是数组,
|
|
||||||
`bool(数组)` 会抛 `ValueError: truth value ... is ambiguous`),改成断言 `metrics["mean"]`;
|
|
||||||
④ 格式:`bounds_arr[:,0]` → `[:, 0]`、函数间空行(`def random_walk` 前只留了 1 个空行)
|
|
||||||
- **测试:新增 4 条 + 摘掉 1 条 skip**(`solve` 段共 30 条)—— 1D 收敛到 1/3(n=2e5)、
|
|
||||||
**3D 抓体积符号**(奇数维)、**`[0,2]` 抓宽度**(偶数维藏不住)、2D + `name`/`dimension`/`bounds` 三个 params、
|
|
||||||
`(1, 0)` 抛 `ValueError`。三条"抓符号"用例就是照着上面那个"2D 掩盖效应"设计的
|
|
||||||
- ✅ **3 条待办已由用户第二轮办结**(同日):`message` 改成 `if result.success:` / `else:` 两支(失败语义不再被覆盖);
|
|
||||||
入口补 `isinstance(bounds, (list, tuple))` + `ndim == 2 and shape[1] == 2` 两道(`[0, 1]` 从裸 `IndexError` 变成可读 `ValueError`);
|
|
||||||
`n` 注解放宽为 `int | np.integer`。⏳ 尚有 2 条新口径(`bounds` 收不收 ndarray、`if`/`else` 三行重复)见"下一步"
|
|
||||||
- [x] `solve/simulate.py` 的 `random_walk`(**L1,S 档,算法由用户本人写**,2026-10-05)
|
|
||||||
- 核心:`step_sampler(rng, size=(n_paths, n_steps))` 一次抽完整批增量 → `np.cumsum(..., axis=1)` →
|
|
||||||
首列填 `start`、其余列 `start + cumsum`。`data` 形状 `(n_paths, n_steps + 1)`
|
|
||||||
- **Codex 改了一处真 bug(决策 31,纯 bug)**:默认采样器写 `rng.integers(0, 2, size=size, dtype=float)`,
|
|
||||||
而 numpy 的 `integers` 不收 `dtype=float`(`TypeError: Unsupported dtype dtype('float64') for integers`)
|
|
||||||
—— **默认路径 100% 失败**(`success=False`、`data=None`),连 docstring 的 Examples 都跑不通。
|
|
||||||
改成 `rng.integers(0, 2, size=size) * 2 - 1`(后面的 `np.asarray(..., dtype=float)` 负责转 float)。
|
|
||||||
实测:`(5, 101)` 形状、`diff` 恒为 ±1、同种子可复现
|
|
||||||
- **Codex 只碰工程侧**:① ruff 两处 W293(只有空格的行);② docstring 补全(原只有 `step_sampler` 一节,
|
|
||||||
现补齐 `n_steps` / `n_paths` / `start` / `random_state` + `Returns` / `Raises` / `Notes`,
|
|
||||||
并写清 `step_sampler` 收的是**二维** `size=(n_paths, n_steps)`);③ Examples 增加一条"步长恒为 ±1"的断言
|
|
||||||
- **测试:新增 6 条**(此前 `random_walk` **一条测试都没有**)—— 形状与 `start` 列、默认步长恒 ±1、
|
|
||||||
同种子可复现、自定义高斯采样器(带漂移)、3 类非法入参、docstring Example 真能跑
|
|
||||||
- **用户第二轮(2026-10-05)按 4 条待办改定**:`n_steps` / `n_paths` 拆成 `TypeError`(类型)/ `ValueError`(取值);
|
|
||||||
加 `steps.shape != (n_paths, n_steps)` 与 NaN 两条校验;默认采样器换成 `rng.choice([-1.0, 1.0], size=size)`;
|
|
||||||
`params` 补 `random_state`;`metrics` 由"抄 params"改成 `mean_terminal` / `std_terminal`(终点位置,`ddof=0`)
|
|
||||||
- **Codex 第二轮又抓到一处真 bug(决策 31)**:用户把 `call_params = {...}` 写进了 `try` 里,而 `except` 分支要引用它 ——
|
|
||||||
**5 种失败输入 5 个全炸** `UnboundLocalError`(本该返回 `success=False` 的 `Result`,变成把异常抛给调用方)。
|
|
||||||
按原意把 `call_params` 移到 `try` 之前(不动任何口径),实测失败路径全部恢复正常;新增一条"必然失败"的回归测试(易错点 26)
|
|
||||||
- **测试:本轮再 +3 → 共 9 条**(新增"失败也必须返回 Result 且带 params"、NaN 判失败、`√t` 扩散);
|
|
||||||
`start="a"` 那条恢复成 `pytest.raises(TypeError)`(用户恢复了入口校验,见下)
|
|
||||||
- **第三轮(2026-10-05)**:`start` 校验恢复、注解放宽为 `int | np.integer`;最后一条"方阵歧义"用户选**方案 B**,
|
|
||||||
`step_sampler` 契约改为"整数 `size=n_paths * n_steps` + 必须返回一维 + 内部行主序 `reshape`"(决策 55,Codex 代改)
|
|
||||||
|
|
||||||
- [x] `solve/simulate.py` 的 `markov_simulate`(**L1,S 档,算法由用户本人写**,2026-10-05)
|
|
||||||
- 核心:`arr[current_states]` 取当前状态那一行 → `cumsum` 得累积分布 → 用 `[0, 1)` 均匀数"落格"
|
|
||||||
(`(cum_probs < r[:, None]).sum(axis=1)`)→ 写进下一列。`data` 形状 `(n_paths, steps + 1)`,值是整数状态编号
|
|
||||||
- **Codex 审查结论:算法本体正确**(探针实测 `(2000, 201)`、状态 ∈ {{0,1}}、200 步后终点频率
|
|
||||||
`[0.657, 0.343]` 对照平稳分布 `[2/3, 1/3]`、同种子可复现)
|
|
||||||
- **Codex 只碰工程侧**:① ruff 8 条全清(`I001` 导入排序 + 5 处 `W293` / 2 处 `W291` 空白);
|
|
||||||
② `arr, kind = as_array(...)` 的 `kind` 全程未用 → 改 `arr, _`;
|
|
||||||
③ docstring 从一行补到完整的 `Parameters` / `Returns` / `Raises` / `Notes` / `Examples`;
|
|
||||||
④ 文案:`马尔科夫` → `马尔可夫`(3 处)、`initail` → `initial`、半角逗号 → 全角;`paths[:,0]` → `paths[:, 0]`
|
|
||||||
- **测试:新增 9 条**(此前 `markov_simulate` 一条都没有)—— 形状 / 首列 / 状态取值 / 整数 dtype、
|
|
||||||
单位矩阵吸收态自锁、可复现(同种子相同、异种子不同)、终点频率收敛到 `[2/3, 1/3]`、
|
|
||||||
metrics = 终点状态、非法转移矩阵(非方阵 / 行和不为 1)、非法 `steps` / `n_paths`(0 / float / bool)、
|
|
||||||
`np.int64` 可收、docstring Example 真能跑
|
|
||||||
- **第二轮(2026-10-09)**:用户补了 `np.any(arr < 0)` 非负校验 → 待办 ② 办结;
|
|
||||||
Codex 补一条"行和是 1 但含负元素"的回归测试
|
|
||||||
- **第三轮(2026-10-10,用户改定)**:非负校验改带容差 `-1e-12`(⑦ 办结);
|
|
||||||
`initial` 注解放宽到 `(int, np.integer)`(`np.int64` 已收);`paths` 的构造与首列赋值**挪进 `try`**(③ 办结,
|
|
||||||
长度不匹配/越界不再裸抛);`params` 的键 `n_steps` → `steps`(与参数名对齐)、`initial` 存成可 JSON 化的标量/列表;
|
|
||||||
`metrics` 去掉 `std_terminal`、换成 `terminal_distribution`(`{状态: 频率}` dict,⑤ 办结)
|
|
||||||
—— Codex 同步 docstring 与 3 条测试(含"只列出现过的状态"这条口径)
|
|
||||||
- ⏳ **余下 4 条**见"下一步 → markov_simulate 待你拍板"(全是入口口径与少考虑情况,决策 49)
|
|
||||||
|
|
||||||
- [x] `solve/simulate.py` 的 `markov_steady_state`(**L1,S 档,算法由用户本人写**,2026-10-09)
|
|
||||||
- 核心:`v` 从均匀分布出发,`v_next = v @ arr` 反复推进(行随机矩阵按**行向量右乘**),
|
|
||||||
直到 `max|v_next - v| < tol` 或耗尽 `max_iter`;`data` 是平稳分布向量,`metrics={"converged", "iterations"}`
|
|
||||||
- **Codex 审查结论:算法本体正确**(探针实测)—— 2 状态 `[2/3, 1/3]`(58 次迭代)、3 状态与 `np.linalg.eig(P.T)`
|
|
||||||
特征值 1 对应的特征向量逐位一致(39 次)、随机 6 状态一致(13 次)、200×200 一致到 `1.9e-12`(6 次、0.10s)、
|
|
||||||
与 `markov_simulate` 的终点频率互验 `[0.6702, 0.3298]` vs `[0.6667, 0.3333]`、`sum(data) = 1.0`
|
|
||||||
- **Codex 只碰工程侧**:① ruff 5 条 `W293` 全清,`mm1_metrics` 前 4 个空行 → 2;
|
|
||||||
② docstring 补 `max_iter` / `tol` 两个参数(原来 Parameters 里只有 `transition`)、补 `Raises`,
|
|
||||||
写清 `tol` 是**相邻差口径**(实测慢混合链 `tol=1e-10` → 实际误差 `4.6e-9`,被 `1/(1-|λ2|)` 放大);
|
|
||||||
③ 修正 Notes 里"周期链会不收敛"的说法(可约链的实情是"平稳分布不唯一、迭代法给离初值最近的那个");
|
|
||||||
④ 失败文案 `失败: ` → `失败:`(与文件内其它函数统一)
|
|
||||||
- **测试:+8 条、摘掉 1 条 skip**(此前该函数只有 1 条挂着的 skip)—— 2 状态 `[2/3, 1/3]` + `converged` / `iterations`、
|
|
||||||
3 状态与特征分解对拍、`data` 是分布(和为 1 / 非负 / `float64`)、慢混合链迭代次数 > 100、
|
|
||||||
`np.int64` 的 `max_iter`、非法转移矩阵、`max_iter=0` / `tol=0`、docstring Example 真能跑
|
|
||||||
- **第二轮(2026-10-09,用户改定)**:补非负校验(① 办结)、`params` 记 `transition`(⑥ 办结)、
|
|
||||||
`success=converged`(选口径 B,④ 办结)、`tol` 拆成 `TypeError` / `ValueError` 且先判 `bool`(② 办结);
|
|
||||||
Codex 顺手修文案(`max_iter <= 0` 那条写成 "tol 必须是正数")与 `异常: ` → `异常:`,
|
|
||||||
并补 2 条测试(负元素矩阵、`max_iter=3` 未收敛走 `success=False`)
|
|
||||||
- **第三轮(2026-10-10,用户改定)**:非负校验带容差 `-1e-12`(⑦ 办结);`max_iter` 类型错改抛 `TypeError`(⑤ 办结);
|
|
||||||
`tol` 加 `np.isnan` 拦截(③ 办结);非方阵改抛 `TypeError`
|
|
||||||
- ⏳ **只剩 2 条**(⑧ 未收敛带 `data`、非方阵的 `TypeError`/`ValueError` 与 `markov_simulate` 不一致)见"下一步"(决策 57)
|
|
||||||
|
|
||||||
- [x] `solve/simulate.py` 的 `mm1_metrics`(**L1,S 档,算法由用户本人写**,2026-10-09)
|
|
||||||
- 核心:闭式解 `ρ = λ/μ`、`L = ρ/(1-ρ)`、`Lq = ρ²/(1-ρ)`、`W = 1/(μ-λ)`、`Wq = W - 1/μ`;
|
|
||||||
`λ >= μ` 走"系统不稳定"分支返回 `success=False`
|
|
||||||
- **Codex 审查结论:公式全对**(探针实测 3 组参数 `(2,3)` / `(1,4)` / `(0.5,1.0)` 与闭式公式逐位一致,
|
|
||||||
`W = Wq + 1/μ` 恒等式也成立)
|
|
||||||
- **Codex 修了一处边界 bug(决策 31,纯 bug)**:稳定性判断写成 `service_rate < arrival_rate`,
|
|
||||||
而 docstring 与既有测试的口径都是 `λ >= μ` —— 于是 `λ == μ` 掉进 `try`、`rho == 1.0` →
|
|
||||||
`ZeroDivisionError` 被 `except` 吞成 `success=False`(结果碰巧对,message 却是"计算失败:ZeroDivisionError")。
|
|
||||||
两支的可观测输出完全相同(`success=False` / `metrics={}` / `data=None`),改 `<=` 只换 message,故直接修并加回归测试
|
|
||||||
- **Codex 只碰工程侧**:① ruff 6 条(5 处 `W293` + `F541` f-string 无占位符 + `W292` 文件末尾无换行);
|
|
||||||
② 文案:`service_rate` 的报错写成 "tol"、`max_iter` 的也写成 "tol"(同一轮里 3 处参数名抄错)、半角逗号 → 全角;
|
|
||||||
③ `call_params={...}` → `call_params = {...}`;④ docstring 补 `Raises` + 说明 `data` 与 `metrics` 同值
|
|
||||||
- **测试:+6 条、摘掉 2 条 skip**(此前只有 2 条挂着的 skip)—— 闭式公式逐项对拍(含 `W = Wq + 1/μ`)、
|
|
||||||
`λ == μ` 与 `λ > μ` 都判失败且带"不稳定"文案、入口(0 / 负数 / `bool` / 非数值)、
|
|
||||||
`λ == μ` 不能靠 `ZeroDivisionError` 落到失败分支、docstring Example
|
|
||||||
- **第三轮(2026-10-10,用户改定)**:`arrival_rate` 放开到"非负"(`λ = 0` 空系统合法,① 办结)、
|
|
||||||
两个速率都收 numpy 标量(`np.number`,② 办结)、不稳定分支**保留 `rho`** 并写进 message(④ 办结)、
|
|
||||||
`data=metrics.copy()` 去掉同一个 dict 对象的别名(③ 部分办结:仍是 dict,只是不再别名)
|
|
||||||
—— Codex 同步 docstring 与 4 条测试
|
|
||||||
- ⏳ **只剩 1 条**(`data` 该不该是 dict)见"下一步 → mm1_metrics 待你拍板"(决策 58)
|
|
||||||
|
|
||||||
- [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**)
|
|
||||||
|
|
||||||
### 验证结果
|
|
||||||
|
|
||||||
| 检查 | venv | conda `mcm-toolkit` |
|
|
||||||
|---|---|---|
|
|
||||||
| `pytest` | 见下(单列) | **233 passed, 25 skipped(0 xfail)** |
|
|
||||||
| `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
|
|
||||||
| `ruff check` | All checks passed | **All checks passed**(0 error) |
|
|
||||||
| `scripts/doctor.ps1` | 跑通 | — |
|
|
||||||
| SALib / pymoo / pyright | 缺失 | 已装 |
|
|
||||||
|
|
||||||
- `pyright`(分析目标 = 工作区 `.venv`):**0 errors, 0 warnings**
|
|
||||||
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
|
|
||||||
`docs/04-environment.md` 坑 7)
|
|
||||||
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
|
|
||||||
- **258 条用例:233 passed / 25 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
|
|
||||||
`test_core.py` 23、`test_solve.py` 64、`test_viz.py` 8、`test_decision.py` 7、
|
|
||||||
`test_stats.py` 53、`test_models.py` 31(第九轮 5 → 23、第十轮 23 → 31)
|
|
||||||
- **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正
|
|
||||||
- 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环
|
|
||||||
- `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz`
|
|
||||||
- 28 条 skip 是等实现的算法(`train_test_split`、`anova`、`fit_distribution`、`knapsack_dp`、
|
|
||||||
`linear_program`、`simulated_annealing` 等);
|
|
||||||
`monte_carlo_probability` / `monte_carlo_integral` / `markov_steady_state` / `mm1_metrics` 已落地,分别摘掉 2 / 1 / 1 / 2 条 skip;`markov_simulate` 新增的 9 条全部实跑;
|
|
||||||
`TestValidate` 的 4 条仍挂着 —— `check_*` 还是 `NotImplementedError`,但它们实现时应当直接复用 `as_array`
|
|
||||||
- **本轮回归(已修)**:`transform.py` 上一版有 3 处真 bug —— 修前 **7 failed / 75 passed**,
|
|
||||||
且 pyright 在同一处报 `reportArgumentType`。**不是静态误报**:pyright CLI 复现 + 测试真红。
|
|
||||||
结论:见到红线先跑 CLI 与 `pytest`,**不许直接挂 `# type: ignore`**(见个人易错点 10)
|
|
||||||
- 三个 `.ps1` 通过 PowerShell AST 语法解析
|
|
||||||
|
|
||||||
### ⚠️ 需要注意 / 遗留
|
|
||||||
|
|
||||||
- [x] ~~两套环境版本不一致~~ → **已解决**:base 升级到 2026.07 后,
|
|
||||||
numpy/scipy/pandas/matplotlib/sklearn/statsmodels/networkx/sympy 版本完全一致,
|
|
||||||
只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。
|
|
||||||
- [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。
|
|
||||||
note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。
|
|
||||||
- [x] L0 完成 **12/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
|
|
||||||
`detect_outliers`、`normalize`、`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`、
|
|
||||||
`monte_carlo`(用户写,2026-10-04));另 L1 的 `confidence_interval` / `t_test` /
|
|
||||||
`monte_carlo_probability` / `monte_carlo_integral` 已提前落地;
|
|
||||||
**L0 已全部完成**(`save_figure` 于 2026-10-18 落地),详情见"下一步"第十一轮
|
|
||||||
- [ ] 本机 `exec_command` 在沙箱模式下**完全不可用**
|
|
||||||
(`helper_unknown_error: setup refresh had errors`),只有提权
|
|
||||||
(`require_escalated`)模式可用。沙箱模式只允许写 `D:\MCM&ICM_Toolkit`。
|
|
||||||
- [ ] **副作用**:沙箱通道(Node 子进程)以 `CodexSandboxOffline` 账户运行,
|
|
||||||
它写出的文件 Owner 是沙箱账户而非 `guzhu`。
|
|
||||||
实测 `guzhu` 对这些文件仍有 Modify 权限(继承自 `Authenticated Users`),
|
|
||||||
读写不受影响;但 git 的 **dubious ownership** 检查会因此误报,
|
|
||||||
所以 `.git` 必须由本人账户创建(见决策 17)。
|
|
||||||
若以后再用沙箱通道写文件,注意同一问题。
|
|
||||||
|
|
||||||
## 决策记录
|
|
||||||
|
|
||||||
| # | 决策 | 理由 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | 包数从 14 砍到 7 | `regression`/`ml` 重复;`optimization`/`simulation`/`sensitivity` 同族;`dynamics`/`network` 低频 → 降级为模块 |
|
|
||||||
| 2 | 只保留 1 个 `Result` | 第一版没有任何函数需要区分 5 种结果类型,属过早分类 |
|
|
||||||
| 3 | 函数清单从 ~250 砍到 ~40 | 4 天赛程的工具,不是 6 个月的框架;没在真题里用过三次的不写 |
|
|
||||||
| 4 | 删掉 `maximize` / 重复的 `normalize` / 13 个 `plot_*` 等 | 同义或一行转发;`plot(kind=...)` 替代 13 个函数 |
|
|
||||||
| 5 | 文档从"每函数 10 项"降到 2 档 | 10 项文档的写作成本是实现的 20 倍,是最可能导致项目停滞的一条 |
|
|
||||||
| 6 | 补上原文缺失的**论文产出链路** `save_table` / `mcm_style` | 美赛是论文比赛;原文只给了一个 `save_figure` |
|
|
||||||
| 7 | 补上**永久不做清单** | 原文用"建议不要",防不住膨胀;写成硬规则 |
|
|
||||||
| 8 | venv 用 `--system-site-packages` | 10.5 MB、离线可用;作为轻量开发与断网兜底 |
|
|
||||||
| 9 | conda 环境用**精准包列表**而非 `--clone base` | 实测 `--clone` 会重放 base 全套包并逐个下载(连 pandoc 都下),远慢于精准列表 |
|
|
||||||
| 10 | 生成的是**签名 + docstring + NotImplementedError** | 用户要自己读代码学算法;骨架定接口,实现留给学习过程 |
|
|
||||||
| 11 | `.ps1` 写成**纯 ASCII**(英文注释/输出),而非中文 | Windows PowerShell 5.1 无 BOM 时按 GBK 解析 `.ps1`,中文直接导致语法错误;保留中文就必须加 BOM,违反 AGENTS.md |
|
|
||||||
| 12 | `.ps1` 用 LF 而非 CRLF | 遵守 AGENTS.md「shell 脚本 UTF-8 无 BOM + LF」;PS 5.1 对 LF 无问题 |
|
|
||||||
| 13 | `chatGPT_Suggestion.md` 保持原样 | 用户手写的输入文件,不属于本次产物,不做无谓改动 |
|
|
||||||
| 14 | base 升级用 `conda update -n base --all`,而不是装新 conda | 计划干净(365 更新 / 0 删除 / 0 冲突),且能保留既有的 `lingbot-map` 环境 |
|
|
||||||
| 15 | 项目环境 numpy **降**到 base 的 2.4.6 | 升 base 的 numpy 会连带把 BLAS 从 MKL 换成 OpenBLAS;宁可降一个 minor,也不动整套线性代数后端 |
|
|
||||||
| 16 | 新增 `.gitattributes` 强制 LF | 仓库要给队友在 Windows/WSL/Linux 上 clone,不固定行尾会产生"整文件都变了"的假 diff |
|
|
||||||
| 17 | `.git` 由**本人账户 guzhu** 重建,不做 `safe.directory` 豁免 | 沙箱通道建的 `.git` Owner 是 `CodexSandboxOffline`,git 会报 dubious ownership。加全局豁免是把问题藏起来;直接以本人账户重建,仓库和远程(Gitea)身份一致 |
|
|
||||||
| 18 | `core.autocrlf=false`(仓库级) | 配合 `.gitattributes` 的 LF 策略,避免 Windows 上 checkout 又转成 CRLF |
|
|
||||||
| 19 | Gitea 的 SSH **走 Tailscale 内网**,不在公网开 22 | 实测公网 `8.145.36.108:22` 超时(服务器侧过滤,本机出站 22 没被封:github.com:22 可通),而 Tailscale `100.115.86.48:22` 可达且 `git@` 认证直接过 Gitea。remote 里仍写域名,域名→内网 IP 的映射放在 `~/.ssh/config`,换机只改一处 |
|
|
||||||
| 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)` **复用同一个模块级生成器**(会推进),不每次新建 | **用户本人提出**,取代 Codex 原来"每次新建 `default_rng(全局种子)`"的写法——那样两个都省略 `random_state` 的函数会抽到完全相同的数。共享单例既保证重跑一致,又让流自然推进 |
|
|
||||||
| 25 | `src/` 下的实现默认由**用户本人**写,Codex 只改 `tests/` 与 `docs/` | 目标是"一边学一边自己写"。曾出现 Codex 直接落盘 `random.py`、与用户编辑器缓冲区冲突导致保存失败。需要代写时用户会明说 |
|
|
||||||
| 26 | 未实现的算法用**逐条** `@pytest.mark.skip`,禁用模块级 `pytestmark` | 模块级 skip 会把同文件里**已经写好**的函数一起跳过。本次 `test_data.py` 就这样让 `load_table` 零覆盖,直到有人手动数才被发现 |
|
|
||||||
| 30 | 已知待修的契约用 `@pytest.mark.xfail(strict=False)`,不用 skip | skip 会让"契约还没写"和"写了但实现有问题"混在一起,5 条 skip 曾把 `clean.py` 的 bug 全藏住。xfail 照样进报告,修好后自动变 XPASS,是"修好了"的信号,而且 XPASS 不失败,不会卡住提交 |
|
|
||||||
| 29 | 静态分析器钉死到工作区 `.venv`,并把 base 的 site-packages 用 `.pth` 链进去 | pyright / Pylance **不认** `pyvenv.cfg` 的 `include-system-site-packages`,`.venv` 自己只有 `pip`;不钉的话它们会去 PATH 上找 python,本机 PATH 上那个是 Codex 运行时的 3.14 shim,没有科学栈,于是报"无法解析导入"。`.pth` 是唯一被 pyright 认的转发手段 |
|
|
||||||
| 28 | 不启用 ruff 的 preview 规则集 | `E302`(函数间要 2 个空行)在 ruff 0.16 里是 preview 规则,`select = ["E"]` 默认**不包含**它——所以少一个空行 ruff 照样报 "All checks passed"。行数空行得自己盯,别指望 lint |
|
|
||||||
| 34 | 常量列(`max == min`)不定区间也不崩:`normalize` 压到 `feature_range` 下界,`standardize` 给 0 | 除零必须有个确定的答案,不能出 nan / inf。给 0 与 `sklearn` 的 `MinMaxScaler` / `StandardScaler` 一致(sklearn 把常量列的 scale 设为 1,结果就是 0),换库不换结论。行为已由 `test_normalize_constant_column_stays_finite` 与 `test_standardize_constant_column_stays_finite` 钉住 |
|
|
||||||
| 33 | `detect_outliers` 只接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`,`dict` 一律 `TypeError` | §4.7 要求非法类型抛 `TypeError` 而非静默兜底。`dict` 以前能"碰巧"被 `pd.DataFrame()` 转成表,但那不是文档承诺的形状,静默接受会让调用方以为传什么都行;`tuple` 则补进文档与 `is_ld_array`,与 `list` 同等对待 |
|
|
||||||
| 32 | `detect_outliers` 对非数值列恒返回 `False`(既不跳过也不报错) | **用户本人在两条路里选的**。跳过会让 docstring 里"与原数据同形"变成谎话(形状对不上,没法按位置对回原表);报错则让一张带 `city` 列的真题表直接不可用。`False` 的读法是"这一列没有离群点",对文本列是最不坏的解释,也让整表能一次跑完 |
|
|
||||||
| 31 | Codex 可**直接改** `src/` 的范围:纯 bug(不改接口与语义)、格式、死代码 | 决策 25 说实现归用户,但"能跑"是底线:`winsorize(pd.Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩、测试变红,这种必须当场修。凡是要**选一个语义**的(文本列返回什么、mode 要不要就地改)一律留给用户本人,Codex 只把选项和影响列清楚 |
|
|
||||||
| 27 | base 里的 zstandard 残骸目录**改名禁用**,不删除、不重装 | 删掉不可逆;`pip install zstandard` 会给 base 塞一个 conda 记录里根本没有的包,下次 `conda update --all` 又要重新判断。改名后 `import` 变回正常 `ModuleNotFoundError`,pandas 自动跳过 |
|
|
||||||
| 35 | `log_transform` 的显式 `offset` 按**定义域前提**校验:`min(x) + offset > 0` 才放行 |
|
|
||||||
| 36 | `describe` 的返回表**不转置**:行 = 指标、列 = 变量 |
|
|
||||||
| 37 | 非数值列的处置**按输出契约分**,不按包分:`describe` / `zscore` 直接丢掉;`detect_outliers` 保留列、掩码恒 `False` |
|
|
||||||
两族函数的产出物不一样:`describe` / `zscore` 吐的是**数值结果表**,文本列塞不进去,丢掉才能得到一张干净、可直接贴论文的表;`detect_outliers` 吐的是**与原表同形的掩码**,必须能按位置对回原表,丢列就废了。"不统一"不是前后矛盾,是契约不同。用户 2026-10-03 拍板:丢掉更好 |
|
|
||||||
| 38 | `coefficient_of_variation` 用 `σ / |mean|`(**用户本人改的**) |
|
|
||||||
均值为负时 `σ/μ` 会给出负值,容易被读成"波动更小",其实只是符号跟着均值走。取绝对值后 CV 恒非负。可比性仍只在均值远离 0 的比率尺度数据上成立(已写进 docstring 的 Notes) |
|
|
||||||
| 39 | 工作约定:**语义 / 非法输入口径由用户拍板,Codex 只列选项与影响、不代选**;**ruff、`STATUS.md`、`CHANGELOG.md`、`tests/`、docstring 这类与算法无关的工程事务,Codex 顺手补掉** |
|
|
||||||
| 40 | scipy 的检验结果统一走 `_pvalue(result: Any) -> float` 助手取 p 值,不逐处挂 `# type: ignore` |
|
|
||||||
| 41 | `correlation_matrix` **只返回数值矩阵**;星标表拆 `correlation_table`、p 值拆 `correlation_pvalues` |
|
|
||||||
用户 2026-10-03 选方案 A。数值矩阵能做的事(挑高相关对、喂聚类 / PCA、画热力图)字符串表都做不了,而反过来随时能格式化 —— 展示需求属于**输出端**,不该侵占返回值;一旦返回字符串,数值信息不可逆地丢失。三个函数共用 `_check_corr_args` 守卫与成对删缺失口径,`correlation_table` 在**最外层**调另外两个(计算型当主、展示型当辅)。见易错点 14 |
|
|
||||||
| 42 | `confidence_interval` **只有 `proportion` 夹到 `[0, 1]`**;`normal` / `t` 是**均值**区间,一律不夹 |
|
|
||||||
原码把三种 method 的区间**无条件**夹进 `[0, 1]`,且 `min` / `max` 参数写反(见易错点 15),实测把身高均值区间下界压成 `0.0`。均值区间的量纲本来就不在 `[0, 1]` 里;只有比例 `p ± z·√(p(1−p)/n)` 的边界才有意义。**Codex 代改、待用户确认** —— 若原意是"什么区间都夹",改回 `_clip` 一行即可 |
|
|
||||||
| 43 | scipy 检验结果的**统计量**也走 `_statistic(result: Any) -> float` 助手,与决策 40 的 `_pvalue` 成对 |
|
|
||||||
`TtestResult` 与 `PearsonRResult` 同源(`_make_tuple_bunch` 运行时拼类),存根里 `.statistic` / `.pvalue` 都没有。用户报的「307 / 308 行报错」就是这两处,**不是真 bug**(探针实测取值正常)。见 `docs/04-environment.md` 坑 10 |
|
|
||||||
| 44 | 所有收数值的函数**统一从 `core.validate.as_array` 进**;**还原不代做**,调用方按返回的 `kind` 自己手写 |
|
|
||||||
用户 2026-10-03 提"每个函数都抄一遍入口/校验/交换太麻烦"。能统一的只有**入口**:出口要求"输出与输入同形",而 `describe` / `correlation_*` / `confidence_interval` / `t_test` 天生不同形,硬套会重演易错点 14。用户明确"不要三个函数""转换回去自己手打",所以 `as_array` 只返回 `(arr, kind)`,`ArraySpec` / `restore` 都不做。`numeric_only` 默认 `True`(用户拍的板),但**按输出契约选**:同形函数必须显式 `False` |
|
|
||||||
| 45 | `as_array` **不删缺失值**;容器类型/内容转换失败**统一抛 `TypeError`** |
|
|
||||||
删缺失是**语义**(`df.corr` 成对删、`describe` 整列统计、`paired=True` 的 t 检验必须成对删),在入口层删掉就把行对应关系永久丢了 —— 这正是 `t_test` 待拍板的口径之一。错误口径统一为 `TypeError` 是**Codex 代选、待用户确认**:`normalize(含文本列的表)` 原先由 numpy 抛 `ValueError`,现在是 `TypeError`(内容转不成 float);`describe` 的 `weights` 同类情况目前仍抛 `ValueError`(决策 39 说不统一就列出来) |
|
|
||||||
| 46 | `bootstrap_ci` 的随机数**无条件走 `core.get_rng`**;`statistic_name` 用 `getattr(f, "__name__", None)` 再判 `"<lambda>"` |
|
|
||||||
原码 `random_state=None` 时另开 `np.random.default_rng()`,等于绕开决策 24(`set_seed` 对它无效,区间不可复现)。**Codex 代改**:`get_rng(None)` 本来就是设计好的"复用模块级生成器且推进",直接传下去即可。`statistic_name` 的 `else "custom"` 是死代码 —— lambda 也有 `__name__`(值恒为 `"<lambda>"`),实测拿不到 `"custom"`,见易错点 17 |
|
|
||||||
`PearsonRResult` / `SignificanceResult` 是 `_make_tuple_bunch` 在**运行时**拼出来的类,存根里没有 `pvalue` 字段:解包会退化成 `object`、属性访问会报"未知"。运行时完全正常(实测三个函数返回的都是 `np.float64`)。收成 `Any` 是**一处**收窄,调用点仍然干净;`# type: ignore` 要挂在每一处,且会连带关掉该行后面的检查。见 `docs/04-environment.md` 坑 10 |
|
|
||||||
用户 2026-10-03 明确:没考虑到的非法情况"告诉我我自己改"——因为那是设计决策,得由写实现的人形成判断;而 lint / 进度板 / 测试这类机械活再由用户动手只是浪费。这条与决策 25 / 31 配套:25 定"谁写 src",31 定"Codex 可改的 bug 范围",39 定"非算法事务的默认归口" |
|
|
||||||
docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向,实现里多做的 `.T` 是净负担 —— 且不是"风格不同"那么轻:转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名零交集,pandas **不报错**、把四行整行填成 NaN。方向约定以"能按列名对齐"为准,见易错点 11 |
|
|
||||||
原码写的是 `if offset > 0: raise`,把唯一合法的用例(正 offset 正好抬定义域)全拒了,报错文案却是"offset 必须大于 0",自相矛盾。改成校验真正的前提:`log` 要求自变量取正。`offset=0` 对全正数据合法(`offset=None` 的自动分支本来就取 0)。**Codex 代选、待用户确认** —— 若原意是"只收正 offset",改回即可 |
|
|
||||||
| 47 | **算法一律由用户本人写;工程由 Codex 写**(用户 2026-10-03 明确) |
|
|
||||||
用户原话:"记住算法的东西一定要我来写,以后就算我说让你来写你也不要帮我写,工程我觉得我还可以,你写我看吧"。
|
|
||||||
这条比决策 25 更硬:25 说"默认归用户",47 说"**即便用户改口也不代写**"——目的是保住"一边学一边自己写"的学习路径。
|
|
||||||
工程侧(入口/校验、`tests/`、lint、文档、卡片、`.gitignore`)反过来由 Codex 直接落盘,用户只读;
|
|
||||||
分地盘按**文件**划,不按函数划。决策 39 的"语义由用户拍板"继续有效。
|
|
||||||
|
|
||||||
| 48 | `monte_carlo` 的四个口径由**用户拍板**:实现传 `size=n`;结果含 NaN 判失败;`std` 用 `ddof=0`;出错仍统一返回 `success=False` |
|
|
||||||
用户 2026-10-04 直接改在代码里。① 批量大小由实现传(`sampler(rng, size=n, **kwargs)`):调用方少写一次,
|
|
||||||
代价是**每个 sampler 都必须能接住 `size`**,且 `**kwargs` 里不能再传 `size`(会重复传参)—— 已写进 docstring。
|
|
||||||
② 结果里只要出现 NaN 就 `success=False`:MC 的 NaN 多来自除零 / 模型发散,静默统计只会得到一列 nan 指标。
|
|
||||||
③ `np.std` 默认 `ddof=0`,现在显式写明(与 `describe` 那条线的 `ddof=1` 不一致,属已知差异)。
|
|
||||||
④ 计算期异常仍全部包成 `success=False`(保留 `try/except Exception`);代价是自己写错代码时没有 traceback。
|
|
||||||
配套 Codex 代改:入口 `np.asarray(..., dtype=float)` —— 布尔结果此前会让 `np.std` 抛错(真 bug,已修)。
|
|
||||||
**唯一未定**:`ci_low`/`ci_high` 保持"结果的分位数"(现状)还是改成"均值的置信区间"。
|
|
||||||
|
|
||||||
| 49 | **这类改动归用户本人**:入口没收紧、少考虑情况、不符合简单原则 —— 用户 2026-10-04 明确"交给我改,不要帮我改" |
|
|
||||||
用户原话:"以后像这种入口没收紧、少考虑情况、不符合简单原则的情况交给我改,不要帮我改,把这几条经验写入经验文档"。
|
|
||||||
这是决策 47 的**收窄**:47 把"入口 / 校验重构"划给了 Codex,49 把它收回 —— 凡是**带判断、取舍、设计成分**的,
|
|
||||||
一律由用户写,Codex 只报告 + 列选项(与决策 39 同一逻辑)。判据一句话:**这处改动要不要"想一下才对"** ——
|
|
||||||
要,就留给用户;不要(ruff、格式化、docstring 缺节、`tests/`、`STATUS` / `CHANGELOG`、函数卡片)才由 Codex 顺手补。
|
|
||||||
本轮实例(本该留给用户,Codex 已代改、用户可回退重写):`monte_carlo` 的 `n` 按 §4.7 收严;入口 `np.asarray(..., dtype=float)`。
|
|
||||||
经验已写入 `docs/dev/personal-pitfalls.md` **第 18 / 19 / 20 条**,并把第 1 条(类型判断只写 `int`)**复发 +1**。
|
|
||||||
|
|
||||||
| 50 | `monte_carlo_probability` 的区间由 **Wald 改 Wilson score**;小概率不再判失败(改成 message 警告);`confidence` 记进 `params` |
|
|
||||||
用户 2026-10-05 按"6 条待办"自行改定。Wilson 在 p 贴近 0 / 1 时不会退化成零宽:实测 p=1、n=1000 从 `(1.0, 1.0)`
|
|
||||||
变成 `(0.99617, 1.0)`;p=0 从"判失败"变成 `(0.0, 0.00383)`(原 Wald 是 0)。区间口径与
|
|
||||||
`statsmodels.proportion_confint(method="wilson")` 对齐,已加成对照测试。
|
|
||||||
**遗留**(2026-10-05 第二轮复核,同日**已清**):~~`event: Callable[..., bool]` 注解与"吃整批、返回数组"不符~~ → 已改 `Callable[..., Any]`;
|
|
||||||
警告文案把 p≈1 也说成"小概率事件" —— **用户已于同日改掉**(现在两半都覆盖,见 CHANGELOG 本轮)。
|
|
||||||
|
|
||||||
| 51 | `monte_carlo_integral` 的 `data` = **n 个样本各自的贡献**(`func(X_i) × 体积`);积分估计值看 `metrics["mean"]` |
|
|
||||||
用户 2026-10-05 写完实现。Codex 审查只改了一处**纯 bug**(决策 31):体积 `lows - highs` → `highs - lows` ——
|
|
||||||
奇数维体积为负(1D `x²` 于 `[0,1]` 实测 **-0.3324**、3D `x` 得 **-0.5005**),而 **2D 因两个负号相乘"看着对"**
|
|
||||||
(+0.2499),把 bug 藏住了;改后实测 0.3324 / 0.5005 / 1.9971 全部正确。docstring 与测试都按上述口径写死。
|
|
||||||
**留给用户 3 条**:① `result.message` 无条件覆盖(失败被写成"成功");② `bounds` 形状没校验(`[0, 1]` 抛裸 `IndexError`);
|
|
||||||
③ `n: int` 与全模块的 `int | np.integer` 不一致。
|
|
||||||
|
|
||||||
| 52 | `monte_carlo_integral` 的入口口径由**用户第二轮**改定:`message` 只在成功分支改写;`bounds` 必须是非空 `list`/`tuple` 且形状 `(d, 2)`;`n` 收 `int \| np.integer` |
|
|
||||||
用户 2026-10-05 按 Codex 报的 3 条自行改定。`message` 走 `if result.success:` / `else:`(失败分支保留失败语义并追加追溯信息:
|
|
||||||
维度 / 体积 / 模拟次数)—— 修掉"`success=False` + 一句成功文案"的自相矛盾。`bounds` 入口两道校验把
|
|
||||||
`[0, 1]` 从 `IndexError: too many indices` 变成带当前形状的可读 `ValueError`。遗留 2 条新口径:
|
|
||||||
① `isinstance(bounds, (list, tuple))` 把 `np.array([[0, 1]])` 也拒了,与"尽量传 ndarray"的偏好冲突;
|
|
||||||
② `if` / `else` 两支里 `name` / `params["bounds"]` / `params["dimension"]` 各写了一遍(易错点 20)。
|
|
||||||
|
|
||||||
| 53 | `random_walk` 的默认步长采样器**不能用 `rng.integers(..., dtype=float)`**(numpy 直接抛 `TypeError`);`step_sampler` 收到的 `size` 是**二维** `(n_paths, n_steps)` |
|
|
||||||
用户 2026-10-05 写完第一版。默认采样器原写 `rng.integers(0, 2, size=size, dtype=float) * 2.0 - 1.0` ——
|
|
||||||
`Generator.integers` 不收浮点 dtype(`TypeError: Unsupported dtype dtype('float64') for integers`),
|
|
||||||
于是**整个默认路径 100% 失败**,连 docstring 的 Examples 都跑不通;Codex 按决策 31 改成 `rng.integers(0, 2, size=size) * 2 - 1`
|
|
||||||
(交给后面的 `np.asarray(..., dtype=float)` 转类型),实测形状 `(5, 101)`、步长恒 ±1、同种子可复现。
|
|
||||||
`step_sampler` 的 `size` 是 `(n_paths, n_steps)` 而不是 `monte_carlo` 那样的一个整数 —— 契约已写进 docstring,
|
|
||||||
**遗留 4 条待办**见"下一步"(`TypeError`/`ValueError` 分工、`steps` 形状校验、NaN 口径、`params` 缺 `random_state`)。
|
|
||||||
|
|
||||||
| 54 | `random_walk` 第二轮口径由**用户**改定:`n_steps`/`n_paths` 类型错抛 `TypeError`、取值错抛 `ValueError`;加形状与 NaN 两条校验;默认步长改 `rng.choice([-1.0, 1.0])`;`params` 补 `random_state`;`metrics` 改 `mean_terminal`/`std_terminal` |
|
|
||||||
用户 2026-10-05 自行改定,Codex 只修了一处**纯 bug**:`call_params` 定义在 `try` 内、却被 `except` 引用,
|
|
||||||
导致**所有失败路径**抛 `UnboundLocalError`(实测 5/5 失败输入全中)。按原意前移到 `try` 之前,未改任何口径。
|
|
||||||
**遗留 3 条**:① `start` 的入口类型校验被删(`start="a"` 从 `TypeError` 变成 `success=False`),是有意还是顺手删的?
|
|
||||||
② 签名 `n_steps: int` / `n_paths: int` 与运行期收 `np.int64` 不一致(§4.7;`monte_carlo_integral` 的 `n` 已放宽);
|
|
||||||
③ `n_paths == n_steps` 时,返回转置形状的采样器仍会静默通过(形状校验天生发现不了,见易错点 25)。
|
|
||||||
|
|
||||||
| 55 | `step_sampler` 的"方阵歧义"按**方案 B** 解决:`size` 改成整数 `n_paths * n_steps`、必须返回**一维**数组,内部 `reshape(n_paths, n_steps)` |
|
|
||||||
形状校验(决策 54)对"方阵"天生失效 —— 转置 `(n_steps, n_paths)` 正好等于目标 `(n_paths, n_steps)`。
|
|
||||||
**用户 2026-10-05 拍板选 B**(Codex 代改,用户明确要求)。转置 `(n_steps, n_paths)` 与目标 `(n_paths, n_steps)` 在方阵时形状相同,
|
|
||||||
校验从形状上无法区分 —— 根源是**契约本身有歧义**(一个 tuple 尺寸给了两种朝向)。改成整数尺寸 + **要求一维**后,
|
|
||||||
二维返回一律判失败,转置问题从根上消失。顺带与 `monte_carlo` 的 `sampler` 契约(决策 48 的 `size=n`)统一:
|
|
||||||
实测给 `monte_carlo` 写的采样器可以直接丢给 `random_walk`。**关键点:不要用 `.ravel()` 救场** —— 那会把转置的二维摊平,
|
|
||||||
歧义原样回来。**代价(已写进 docstring)**:`step_sampler` 不能再返回二维,步与路径的对应关系是**行主序**。
|
|
||||||
探针实测:默认采样器 `(5, 101)` 正常;后四种(转置二维 / 方阵二维 / 长度 +1 / `(N, 1)`)一律判失败。
|
|
||||||
|
|
||||||
| 56 | `markov_simulate` 的 `initial` 语义**待用户拍板**(Codex 只报选项,未代改,决策 49) |
|
|
||||||
现状用 `isinstance(initial, int)` 区分"标量状态 / 状态数组",两条分支的口径互相打架:标量分支按**路径数**铺满
|
|
||||||
(`np.full(n_paths, initial)`),数组分支却按**状态数**校验长度(`len(initial) != s` 直接报错)——
|
|
||||||
于是数组分支只有 `n_paths == s` 时才可能跑通;而 `paths[:, 0] = current_states` 又写在 `try` **外面**,
|
|
||||||
长度不匹配时**裸抛** `ValueError`(本该 `success=False`,同易错点 26 一族)。另有三个口子:
|
|
||||||
`np.int64(0)` 掉进数组分支、被 `as_array` 拒(与 `steps`/`n_paths` 收 `np.integer` 不一致,易错点 1 复发);
|
|
||||||
`True` 因 `isinstance(True, int)` 为真被当状态 1 **静默放行**;越界状态(如 `initial=5`、`s=2`)只在
|
|
||||||
第一轮索引时才炸、被 `except` 吞成 `success=False`。
|
|
||||||
**2026-10-09 更新**:② 已办结(用户补了 `np.any(arr < 0)`,含负元素的行随机矩阵现在入口直接报错)。
|
|
||||||
**2026-10-10 更新**:③ 办结(`paths` 的构造与首列赋值挪进 `try`,长度不匹配/越界不再裸抛);
|
|
||||||
④ 办结(越界现在被吞成 `success=False`,但仍没在入口拦);⑤ 办结(`std_terminal` → `terminal_distribution`);
|
|
||||||
② 升级为带容差 `-1e-12`;`params` 的 `n_steps` → `steps`、`initial` 存成可 JSON 化的值。
|
|
||||||
**只剩 ①**:`bool` 仍被当状态 1(`np.integer` 已收),以及"数组按状态数 vs 按路径数"两条口径的矛盾(A / B / C)。
|
|
||||||
|
|
||||||
| 57 | `markov_steady_state` 的口径:**4 条已办、3 条 + 2 个新口子仍待拍板**(Codex 只报选项,未代改,决策 49) |
|
|
||||||
**2026-10-09 用户第二轮办结**:① 补 `np.any(arr < 0)`(含负元素的行随机矩阵现在入口即报错,原先会溢出成 `nan`
|
|
||||||
却报 `success=True`);② `tol` 拆成 `TypeError`(类型 + `bool`)与 `ValueError`(取值);
|
|
||||||
④ **选口径 B**:`converged=False` → `success=False`;⑥ `params` 补 `transition`(`arr.tolist()`,可 JSON 化)。
|
|
||||||
**仍待拍板**:③ `tol=np.nan`(`nan <= 0` 为假,穿过校验白跑 §max_iter 次迭代);
|
|
||||||
⑤ `max_iter` 的类型错仍抛 `ValueError`(与决策 54 的"类型错 `TypeError`"不一致);
|
|
||||||
⑦ **两把尺子宽严不一**——非负校验用精确 `arr < 0`,同一个函数里的行和校验却用宽容的 `np.allclose`,
|
|
||||||
实测 `[[1.0, -1e-16], [0.5, 0.5]]`(行和都是 1)会被判非法,建议改成 `< -1e-12` 这类容差;
|
|
||||||
⑧ 未收敛时 `success=False` 但 `data` 仍是未收敛向量(文件内唯一"失败却带 `data`"的分支)—— 保持还是 `data=None`?
|
|
||||||
**2026-10-10 更新**:③(`tol` 拦 NaN)、⑤(`max_iter` 类型错 → `TypeError`)、⑦(非负校验带容差)办结。
|
|
||||||
**只剩 2 条**:⑧(上面那条),以及新出现的"非方阵抛 `TypeError`,而 `markov_simulate` 的同一条校验抛 `ValueError`"不一致。
|
|
||||||
|
|
||||||
| 58 | `mm1_metrics` 的稳定性分界由 Codex 按**决策 31** 直接修(`<` → `<=`),另有 4 条口径待用户拍板 |
|
|
||||||
docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `service_rate < arrival_rate` —— 于是 `λ == μ`
|
|
||||||
掉进 `try`、`rho == 1.0` → `ZeroDivisionError`,被 `except` 吞成 `success=False`
|
|
||||||
(碰巧对,但 message 成了"计算失败:ZeroDivisionError - float division by zero")。两支的可观测输出完全相同
|
|
||||||
(`success=False` / `metrics={}` / `data=None`),改 `<=` 只换 message,故直接修 + 加回归测试。
|
|
||||||
**待拍板**:① `λ = 0` 现在被 `ValueError` 拒(数学上是合法退化解:空系统 `L = W = 0`);
|
|
||||||
② `np.int64` / `np.float32` 全被 `TypeError` 拒(文件内 `random_walk` 的 `start` 收 `np.number`、
|
|
||||||
整数参数收 `np.integer`,§4.7 有共识);③ `data=metrics` —— **同一个 dict 对象**(`r.data is r.metrics`),
|
|
||||||
且成功分支给 dict、失败分支给 `None`,与文件内"`data` 是数组"的习惯不同;
|
|
||||||
④ 失败分支的 `metrics={}` 会丢掉 `rho` 等已算出的量(可选保留)。
|
|
||||||
**2026-10-10 更新**:① ② ④ 办结(`λ = 0` 放开为合法空系统、两个速率收 `np.number`、失败分支报 `rho`);
|
|
||||||
③ 只办了一半(`data=metrics.copy()` 去掉了同一个 dict 对象的别名,但 `data` 仍是 dict、失败时仍是 `None`)—— 只剩这一条。
|
|
||||||
|
|
||||||
| 59 | `models/metrics.py` 开工:6 个指标 + 共享入口 `verify_yt_yp` **由用户写**,Codex 只做工程侧审查(决策 47 的延续) |
|
|
||||||
本轮把 `metrics.py` 的 L1 `TODO` 转成实现(`compare_models` 仍 `NotImplementedError`)。六个指标共用一个入口
|
|
||||||
`verify_yt_yp`:`as_array(ndim=1)` → 形状不一致 / 长度 0 / 含 `NaN` 一律 `ValueError`(与决策 48 的"NaN 判失败"同口径)。
|
|
||||||
Codex 侧(决策 47 授权范围):补 7 处 docstring(`Parameters` / `Returns` / `Raises`)、统一报错文案为"参数名 + 实际值"、
|
|
||||||
清 6 条 `F541` + 3 条 `W293`、补空行;`tests/test_models.py` 两条指标测试转正 + 新增 18 条。
|
|
||||||
**用户侧拍板并亲手改**:`r2_score` 的 `ss_tol` 基准从 `np.mean(yp)` 改成 `np.mean(yt)`(真 bug,见决策 60 与易错点 31),
|
|
||||||
顺带把无意义的 `ss_tol` 改名 `ss_tot`。Codex 探针实测:修前 0.8803827751196172、修后 0.875 = `sklearn.metrics.r2_score` 一致。
|
|
||||||
**另有 4 条口径仍待拍板**:`mape` 遇负 `y_true`(现在返回无意义的负数)、只拦 `NaN` 不拦 `inf`、
|
|
||||||
`rmse(1.0, 1.0)` 标量被当 1 个样本、`compare_models` 的键名 `"r2"` 与函数名 `r2_score` 对不上。
|
|
||||||
|
|
||||||
| 60 | `smape` 的零除告警按**决策 31** 由 Codex 直接修(`np.where` → `np.divide(..., where=)`),数值结果不变 |
|
|
||||||
原式 `np.where(denom == 0, 0.0, 2 * |yt - yp| / denom)` 的**结果是对的**(分母 0 记 0),但 `np.where` 两条分支都会被求值,
|
|
||||||
分母为 0 时仍触发 `RuntimeWarning: invalid value encountered in divide`;而 `pyproject.toml` 开了
|
|
||||||
`filterwarnings = ["error::RuntimeWarning"]` —— 于是任何"含 0 的 smape"测试都会直接红(探针复现)。
|
|
||||||
改成 `np.divide(..., out=np.zeros_like(denom), where=denom != 0.0)` 后告警消失、数值与口径完全不变,
|
|
||||||
并补了一条测试把"不许告警"钉住(`test_smape_is_silent_when_denominator_is_zero`)。
|
|
||||||
**判据**:只是防告警、不改语义 → 工程侧;如果是"改公式"(如决策 59 的 r2),一律留给用户(决策 49)。
|
|
||||||
| 61 | `models/metrics.py` 的三条入口口径由**用户第三轮改定**(`Inf` / 至少 2 个样本 / `mape` 拦非正数),Codex 只同步 docstring + 测试 |
|
|
||||||
前一轮报的 4 条待拍板里,用户改了前三条:① `verify_yt_yp` 增加 `np.isinf(yt).any() or np.isinf(yp).any()`
|
|
||||||
(只拦 `NaN` 时 `rmse([1.0, inf], ...)` 会安静地返回 `inf`);② 增加"至少 2 个样本"(1 个点的误差指标没有统计意义,
|
|
||||||
`r2_score` 更是直接无定义),**标量也因此被拦住**(不再被当成 1 个样本悄悄算);③ `mape` 从"只拦 0"改成
|
|
||||||
`np.any(yt <= 0)`(原来的 `mean(|yt - yp| / yt)` 在 `yt < 0` 时返回**负数**,实测 `-1.0`)。
|
|
||||||
Codex 侧:把这三条口径写进 7 处 `Raises`(**又是易错点 30**:口径一改、六处 docstring 立刻过期,本轮`复发 1`);
|
|
||||||
`test_mape_rejects_zero_in_y_true` 的 `match` 文案随新消息改为"非正数",并新增 `Inf` / 至少 2 个样本两条测试。
|
|
||||||
**遗留小问题(Codex 没动)**:`verify_yt_yp` 里 `yt.ndim == 0` 那一支是**死代码** ——
|
|
||||||
`as_array(..., ndim=1)` 必然 `reshape(-1)`,`ndim` 恒为 1,可整段塌成 `if yt.shape[0] <= 1:`(真正的判定条件)。
|
|
||||||
|
|
||||||
| 62 | `compare_models` 由 Codex 按 **A 档**落地,指标名口径选**别名映射**(用户 2026-10-12 拍板) |
|
|
||||||
前一轮问的是"`metrics=("rmse","mae","r2")` 里的 `"r2"` 与函数名 `r2_score` 对不上,用哪个"。用户选**两个都收**,
|
|
||||||
所以别名表落在模块级的 `_METRIC_FUNCS`(`"r2"` 与 `"r2_score"` 指同一个函数):**传哪个名字,输出表的列就叫哪个名字**,
|
|
||||||
两个一起传报 `ValueError`(否则会出两列一模一样的数字)。别名只解决"名字认不认",不改变数值。
|
|
||||||
实现要点:`models` 必须传**模型对象**(`fit` / `predict`),不是拟合结果;`X` 必须二维(`as_array(ndim=2)` 不替你猜);
|
|
||||||
分折是**手写 K 折**(`get_rng(random_state).permutation` + `np.array_split`),**不依赖 sklearn** —— 只装了 4 个核心依赖也能跑;
|
|
||||||
`cv=0` 全量拟合后打分(`cv=1` 报错:要么 0 要么 ≥2);`cv >= 2` 报**折间均值**;每折至少 2 个样本 ⇒ `cv <= n // 2`;
|
|
||||||
排序方向跟着**第一个指标**走(误差类升序、R² 降序)。`adjusted_r2` **不进表**(还要 `n_features`,`compare_models` 不知道每个模型用了几列)。
|
|
||||||
测试 23 → 31 条:首列排序(升序 / 降序两条)、别名、坏指标名、坏 `models` / `cv`、每折样本数下限、`random_state` 可复现。
|
|
||||||
## 下一步
|
|
||||||
|
|
||||||
### ⭐ 当前进度与待办(2026-10-18 第十一轮)
|
|
||||||
|
|
||||||
**本轮:L0 收官 —— `save_figure` 落地(Codex 写,A 档)。**
|
|
||||||
|
|
||||||
- `viz/export.py`:实现 `save_figure`。入口先校验 `fig` / `dpi` / `formats` / `bbox_inches` /
|
|
||||||
`transparent` / `path`,**全部校验通过后才建目录、写文件**;`formats` 支持大小写归一,
|
|
||||||
空 / 未知 / 重复格式一律报错;`path` 带图片后缀时自动去后缀(不会写出 `fig1.png.png`)。
|
|
||||||
- `tests/test_viz.py`:去掉违反决策 26 的模块级 `pytestmark`,改为逐条 skip;
|
|
||||||
`test_save_figure_writes_png_and_pdf` 不再依赖未实现的 `plot()`,改用 `plt.subplots()` 构造图。
|
|
||||||
新增 8 条:后缀剥离、自动建目录、格式归一与顺序、单个字符串 / 空 / 未知 / 重复格式、
|
|
||||||
校验先于副作用、坏 `dpi` / `bbox_inches` / `transparent` / 非 `Figure`。
|
|
||||||
- 三连:`pytest` **242 passed / 24 skipped**、`ruff` **0**、`pyright` **0**。
|
|
||||||
|
|
||||||
**下一步**
|
|
||||||
1. `models/regression.py` 的 `fit_linear` / `fit_polynomial`(A 档)。**开工前先定返回契约**:
|
|
||||||
`Result` 没有 `fit/predict/score`,与 `compare_models` 要求 `Model` 协议的入参对不上,
|
|
||||||
需要定成"`Result.data` 放已拟合模型 + `compare_models` 接受两种入参"之类的桥接方案。
|
|
||||||
2. `decision/entropy_weights`(S 档,用户写);Codex 先补函数卡片 / 测试骨架。
|
|
||||||
3. 集中拍板 `STATUS.md` 里挂着的小口径(`t_test` / `as_array` / `bootstrap_ci` / `markov_*` / `mm1` / 死代码)。
|
|
||||||
|
|
||||||
### ⭐ 当前进度与待办(2026-10-12 第十轮)
|
|
||||||
|
|
||||||
**本轮:你办掉第九轮那 4 条口径里的 3 条,Codex 落地 `compare_models`(别名映射)。**
|
|
||||||
|
|
||||||
你改的三条(决策 61):`verify_yt_yp` 拦 `Inf`、拦"只有 1 个样本"、`mape` 从"只拦 0"改成 `yt <= 0`。
|
|
||||||
三条我都复测过:`rmse([1.0, inf], [1.0, 2.0])` 现在报 `ValueError`;`rmse(1.0, 1.0)` 也报错(标量不再被当成 1 个样本);
|
|
||||||
`mape([-1.0, 2.0], [1.0, 2.0])` 由返回 `-1.0` 变成报错。
|
|
||||||
|
|
||||||
Codex 这轮:① 把三条新口径写进 7 处 `Raises`(口径一改、docstring 就过期 —— **易错点 30 复发 1 次**);
|
|
||||||
② `compare_models` 按 A 档实现(决策 62),指标名走**别名映射**:`_METRIC_FUNCS` 里 `"r2"` 与 `"r2_score"` 指向同一个函数,
|
|
||||||
传哪个列名就叫哪个、两个一起传报错;③ 顺手补了 `X` 必须二维、`cv` 白名单(`bool` 不收、`cv=1` 无意义、`cv <= n // 2`)、
|
|
||||||
`models` 必须是"有 `fit`/`predict` 的对象"等入口;④ 测试 23 → 31 条。
|
|
||||||
|
|
||||||
三连:`pytest` **233 passed / 25 skipped(258 收集)**、`ruff` **0**、`pyright` **0**。
|
|
||||||
|
|
||||||
**接下来(`models` 的完成标志 `compare_models` 已达成)**
|
|
||||||
1. `regression.py` 的 `fit_linear` / `fit_polynomial`(A 档薄封装;把 `base.Model` 三件套跑通,
|
|
||||||
顺带把 `tests/test_models.py` 里那两条**还挂着的 `skip`** 转正)。**注意**:`fit_linear` 落地后要补一条
|
|
||||||
"`compare_models` + 真实模型"的集成测试 —— 原来那条跳过用例写的是 `compare_models({"linear": fit_linear(X, y)})`,
|
|
||||||
**把拟合结果当模型传是错的**,本轮已把它改写成用测试桩(`_MeanModel` / `_LinearModel`)。
|
|
||||||
2. `models` 剩下的 `fit_nonlinear` / `timeseries` / `ml` 按 roadmap 走。
|
|
||||||
3. 之后转 `decision/entropy_weights`(**S 档,你写**,三个坑:方向 / 量纲 / 零值)。
|
|
||||||
|
|
||||||
### ⏳ 第十轮新出现的 1 条小口径(Codex 只报不改,决策 49)
|
|
||||||
|
|
||||||
**`verify_yt_yp` 里 `yt.ndim == 0` 那一支是死代码**:`as_array(..., ndim=1)` 内部一定 `ravel()`,`ndim` 恒为 1,
|
|
||||||
所以 `if yt.ndim == 0 or yt.shape[0] <= 1:` 里前半段永远为假,报错文案里的 `else 1` 也永远走不到。
|
|
||||||
整段可以塌成 `if yt.shape[0] <= 1:`(行为完全不变,只是少一段"看着像在防什么、其实没防"的代码)。
|
|
||||||
你决定改不改 —— 不改也能跑,只是以后回头看会多花几秒钟。
|
|
||||||
|
|
||||||
### ⭐ 当前进度与待办(2026-10-10 第九轮)
|
|
||||||
|
|
||||||
**本轮:`models` 开工,你亲手写了 `models/metrics.py`** —— 6 个回归指标(`rmse` / `mae` / `mape` / `smape` /
|
|
||||||
`r2_score` / `adjusted_r2`)加一个共享入口 `verify_yt_yp`;`compare_models` 仍是 `NotImplementedError`。
|
|
||||||
Codex 只碰工程侧:7 处 docstring(补 `Parameters` / `Returns` / `Raises`,把口径写成契约)、报错文案统一成
|
|
||||||
"参数名 + 实际值"、清 6 条 `F541` + 3 条 `W293` + 空行、`tests/test_models.py` 5 条 → 23 条(两条转正 + 18 条新增)。
|
|
||||||
|
|
||||||
**Codex 找到 1 个真 bug(已由你亲手改定)**:`r2_score` 的 `ss_tot` 用了 `np.mean(yp)`,基准必须是 `np.mean(yt)`。
|
|
||||||
探针实测(`y_true=[1,2,3,4,5]`、`y_pred=[1.5,2.5,2.5,4.5,5.5]`):修前 **0.8803827751196172**、修后 **0.875**,
|
|
||||||
与 `sklearn.metrics.r2_score` 完全一致。它会连带污染 `adjusted_r2`。**注意 `r2_score(y, y) == 1` 仍成立** ——
|
|
||||||
完美拟合与常量 y 都会掩盖这类错,所以新增的测试特意用"非完美拟合"的数据(见个人易错点 31)。
|
|
||||||
|
|
||||||
**另修一处会真炸的工程问题(决策 60)**:`smape` 的 `np.where` 掩码防不住零除,`filterwarnings=error::RuntimeWarning`
|
|
||||||
下任何含 0 的测试都会红 → 改 `np.divide(..., where=)`,数值结果不变,并补测试钉住"不许告警"。
|
|
||||||
|
|
||||||
三连:`pytest` **224 passed / 26 skipped(250 收集)**、`ruff` **0**、`pyright` **0**。
|
|
||||||
|
|
||||||
**接下来(按 roadmap §四,`models` 的完成标志就是 `compare_models`)**
|
|
||||||
1. `compare_models`(同文件最后一块;`cv=0` 走"全量拟合后打分"分支,返回按第一列指标排序的 `DataFrame`,
|
|
||||||
依赖 `pd.DataFrame`,顺带定 `metrics` 的键名口径)。
|
|
||||||
2. `regression.py` 的 `fit_linear` / `fit_polynomial`(把 `models/base.py` 的 `Model` 协议三件套跑通)。
|
|
||||||
3. 然后转 `decision/entropy_weights`(**S 档,你写**,三个坑:方向 / 量纲 / 零值)。
|
|
||||||
|
|
||||||
### ✅ 第九轮那 4 条口径(2026-10-12 全部办结)
|
|
||||||
|
|
||||||
1. ~~`mape` 遇负 `y_true` 返回负数~~ → **用户选"拦"**:改成 `np.any(yt <= 0)` 直接报错并点名改用 `smape`
|
|
||||||
(另一条路是用 `|y_true|` 做分母,没选)。测试文案随之改成 `match="非正数"`。
|
|
||||||
2. ~~`inf` 不拦~~ → **用户选"跟 `NaN` 一样拦"**:`verify_yt_yp` 增加 `np.isinf(...).any()`(`rmse` 不再安静地返回 `inf`)。
|
|
||||||
3. ~~标量被当成 1 个样本~~ → **用户选"要求至少 2 个点"**,于是 `rmse(1.0, 1.0)` 现在报 `ValueError`。
|
|
||||||
注意:这与 `as_array` 白名单里"标量要不要摘掉"是**两个问题**(那条仍未拍板,见下方"其余待办"第 6 条)。
|
|
||||||
4. ~~`compare_models` 的键名 `"r2"` vs `"r2_score"`~~ → **用户选别名映射**(两个都收),由 Codex 实现,见决策 62。
|
|
||||||
|
|
||||||
### ⭐ 当前进度与待办(2026-10-10 第八轮)
|
|
||||||
|
|
||||||
**本轮:你按第七轮的清单改了 5 处口径** —— `markov_simulate`(非负校验带容差、收 `np.integer`、赋值挪进 `try`、
|
|
||||||
`metrics` 换成 `terminal_distribution`)、`markov_steady_state`(容差、`max_iter` 的 `TypeError`、
|
|
||||||
`tol` 拦 NaN)、`mm1_metrics`(`λ = 0` 放开、收 numpy 标量、失败分支报 `rho`、`data` 去别名)。
|
|
||||||
**三处算法本体仍然正确**(探针复测:终点分布 `{0: 0.6644, 1: 0.3356}` 对照 `[2/3, 1/3]`;
|
|
||||||
`λ = 0` 给 `L = Lq = Wq = 0`、`W = 1/μ`;`r.data is r.metrics` 已为 `False`)。
|
|
||||||
Codex 只同步 docstring + 6 条测试、清 ruff(`B905` 的 `zip` 缺 `strict` + 2 条空白)。
|
|
||||||
三连:`pytest` **204 passed / 28 skipped**、`ruff` 0、`pyright` 0。
|
|
||||||
|
|
||||||
**接下来三件事**
|
|
||||||
1. 下面只剩 **5 条小口径**,基本都是"一个值 / 一个类型"级别的决定。
|
|
||||||
2. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。
|
|
||||||
3. 之后往 `models` / `decision` 走(见下方第八轮备忘)。
|
|
||||||
|
|
||||||
### ⏳ 只剩这 5 条口径待你拍板(Codex 只报不改)
|
|
||||||
|
|
||||||
**① `markov_simulate` 的 `bool` 漏网**:`isinstance(True, (int, np.integer))` 为真 → `initial=True` 被当成状态 1。
|
|
||||||
实测现在**不再静默给错答案**(`np.full(n_paths, True)` 让后面 `arr[current_states]` 变成布尔掩码、崩掉后吞成 `success=False`),
|
|
||||||
但 message 是个看不懂的广播错误 —— 建议入口先判 `bool` → `TypeError`,把易错点 1 的最后一格补上。
|
|
||||||
|
|
||||||
**② `markov_simulate` 数组分支的长度口径**(决策 56 的 ①,仍是最大的那条):标量按**路径数**铺满、数组按**状态数**校验,
|
|
||||||
两条对不上(只有 `n_paths == s` 才通)。A / B / C 三个选项见决策 56。
|
|
||||||
|
|
||||||
**③ `markov_steady_state` 的 `data`**:`converged=False` → `success=False`,但 `data` 仍是未收敛向量
|
|
||||||
(文件内唯一"失败却带 `data`"的分支)。保持(B)还是 `data=None`(C)?
|
|
||||||
|
|
||||||
**④ 非方阵的异常类型两处不一致**:`markov_steady_state` 抛 `TypeError`、`markov_simulate` 抛 `ValueError`
|
|
||||||
(同一条"不是二维方阵"的校验)。numpy 自己对形状问题抛的是 `ValueError`;要统一成哪个?
|
|
||||||
|
|
||||||
**⑤ `mm1_metrics` 的 `data`**:现在是 `metrics.copy()`(不再是同一个对象),但仍是 **dict**,失败分支是 `None`。
|
|
||||||
选项:**A** 保持;**B** `data=None`;**C** `data` 给一个 5 元素数组。
|
|
||||||
|
|
||||||
### 📦 第八轮备忘:`models` 与 `decision` 是干什么的
|
|
||||||
|
|
||||||
这两个包**目前一行都没实现**(全部 `TODO` + `NotImplementedError`),是 `solve` 之后的两大块。分工完全不同:
|
|
||||||
|
|
||||||
| | `models` —— 建模 | `decision` —— 评价决策 |
|
|
||||||
|---|---|---|
|
|
||||||
| 回答的问题 | "这条规律是什么?能不能预测?" | "这几个方案哪个好?" |
|
|
||||||
| 输入 | 特征 `X` + 目标 `y`(有监督)或只有 `X`(聚类/降维) | **决策矩阵**(行 = 方案、列 = 指标),通常无标签 |
|
|
||||||
| 输出 | 模型对象 + 预测值 + 指标;`compare_models` 出对比表 | 权重向量 + 排序表(`rank`) |
|
|
||||||
| 主要依赖 | sklearn / statsmodels(**薄封装**) | numpy 手写为主(S 档:熵权、AHP、GM(1,1)、TOPSIS) |
|
|
||||||
| 美赛场景 | 预测题(人口、销量、气温)、"要不要上复杂模型" | 中文赛 / ICM 的评价题(AHP、TOPSIS、灰色关联、熵权) |
|
|
||||||
| roadmap 时间盒 | 第 2–3 周 | 第 4 周 |
|
|
||||||
|
|
||||||
`models`(19 个函数,5 个文件):`base.py` 已写好 `Model` 协议(`fit / predict / score` 三件套,已实现);
|
|
||||||
`metrics.py` 是 8 个指标 + `compare_models`(返回 DataFrame,可直接 `save_table(fmt="latex")`);
|
|
||||||
`regression.py` / `timeseries.py` / `ml.py` 全是"一行转发 + 参数校验 + `Result` 包装"。
|
|
||||||
注意两处:① `rmse` / `mae` / `r2_score` 的注释写的是 `TODO(L0)`,但 roadmap 的 L0 12 项里没有它们,
|
|
||||||
它们属于 L1 的 `models` 清单 —— 注释待统一;② `tests/test_models.py` 用的是**模块级** `pytestmark`,
|
|
||||||
违反决策 26(逐条 skip),开写时要拆开。
|
|
||||||
|
|
||||||
`decision`(8 个函数,4 个文件):`weighting.py`(等权 / 熵权 / CRITIC)、`ahp.py`(判断矩阵 + 一致性比率 `CR`)、
|
|
||||||
`topsis.py`、`grey.py`(灰色关联 + GM(1,1) 预测)。
|
|
||||||
**使用顺序是硬规矩**(`decision/__init__.py` 里写着):先 `normalize` 无量纲化 → 再声明方向 `benefit_columns`
|
|
||||||
→ 再 `entropy_weights` / `ahp_weights` → 最后 `topsis`。跳过第一步直接算权重是评价类题最常见的翻车方式。
|
|
||||||
S 档(要配函数卡片)三个:`entropy_weights` / `ahp_weights` / `gm11_fit_predict`,另有 `topsis` 也标了卡片。
|
|
||||||
永久不做:`dea_efficiency`(要解一串 LP、低频)、`fuzzy_comprehensive_evaluation`(隶属度函数没有通用形式)。
|
|
||||||
|
|
||||||
**建议的开工顺序**:先 `models/metrics.py`(纯函数、最容易、`compare_models` 是"要不要上复杂模型"的裁判,
|
|
||||||
也是 roadmap §四里 `models` 的完成标志)→ 再 `fit_linear` / `fit_polynomial`(把 `Model` 协议跑通)
|
|
||||||
→ 然后 `decision` 的 `entropy_weights`(S 档、20 行、三个坑:方向 / 量纲 / 零值)→ `topsis` → `ahp` → `gm11`。
|
|
||||||
|
|
||||||
### ✅ random_walk 的 4 条待办(2026-10-05 已办结)
|
|
||||||
|
|
||||||
- ~~① 非整数 `n_steps` 抛 `ValueError`~~ → 已拆成两条:类型错 `TypeError`、取值错(`<= 0`)`ValueError`,`n_paths` 同样处理。
|
|
||||||
- ~~② `steps` 没有形状校验~~ → 已加 `steps.shape != (n_paths, n_steps)` 判失败(实测转置采样器现在能拦住)。
|
|
||||||
- ~~③ NaN 不判失败~~ → 已加 `np.isnan(steps).any()`,与 `monte_carlo` 的 NaN 口径(决策 48)拉齐。
|
|
||||||
- ~~④ `params` 少 `random_state` / `metrics` 抄了一遍~~ → 已补 `random_state`;`metrics` 换成 `mean_terminal` / `std_terminal`
|
|
||||||
(终点位置的均值与标准差,`ddof=0`)—— 这才是"游走"该看的指标,测试里还顺手验了 `std ≈ √t`。
|
|
||||||
|
|
||||||
### ✅ random_walk 的口径(2026-10-05 全部办结)
|
|
||||||
|
|
||||||
- ~~① `start` 的入口类型校验被删~~ → 已恢复;文案的"必须是整数"改成"必须是数值"(这条校验本来就收 float),
|
|
||||||
类型名统一 `{type(start).__name__}`。
|
|
||||||
- ~~② 签名注解比行为窄~~ → 已改成 `int | np.integer`,`np.int64` 的测试补回来。
|
|
||||||
- ~~③ `n_paths == n_steps` 的方阵歧义~~ → 用户选**方案 B**(决策 55):`size=n_paths * n_steps`(整数)、
|
|
||||||
要求返回一维、内部 `reshape(n_paths, n_steps)`;二维返回一律判失败。实测转置二维 / 方阵二维 / 长度不符全部拦下,
|
|
||||||
且 `monte_carlo` 风格的采样器可直接复用。
|
|
||||||
|
|
||||||
### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 全部办结)
|
|
||||||
|
|
||||||
- ~~① `p <= 1e-3` 判 `success=False`~~ → 改成 `warning_message`,仍返回区间(实测 p=0 → `(0.0, 0.00383)`)。
|
|
||||||
- ~~② Wald 在 p 靠近 1 时退化~~ → 换 **Wilson**(p=1、n=1000:`(1.0, 1.0)` → `(0.99617, 1.0)`)。
|
|
||||||
- ~~③ `call_params` 没记 `confidence`~~ → 已加。
|
|
||||||
- ~~④ `event` / `sampler` 不可调用被吞成 `success=False`~~ → 已加 `callable` 校验,现在抛 `TypeError`。
|
|
||||||
- ~~⑤ `metrics` 混 `np.float64`~~ → `float()` 统一(实测 4 个 key 全是 `float`)。
|
|
||||||
- ~~⑥ `n: int`~~ → 已改 `int | np.integer`。
|
|
||||||
|
|
||||||
**~~还剩 1 条~~ → 已办(本轮)**
|
|
||||||
1. ~~`event: Callable[..., bool]` 的注解与行为不符~~ → 已改 `Callable[..., Any]`,与 `sampler` / `simulator` 对齐。
|
|
||||||
pyright 复测:向量化事件函数不再被拒(原注解会报 `reportArgumentType`)。**决策 50 的遗留清零。**
|
|
||||||
|
|
||||||
### ✅ monte_carlo_integral 的 3 条待办(2026-10-05 已办结)
|
|
||||||
|
|
||||||
- ~~① `result.message` 被无条件覆盖~~ → 已改成 `if result.success:` / `else:` 两个分支;
|
|
||||||
失败时是"蒙特卡洛多重积分失败:维度 1,体积 1.0000,模拟 100 次。蒙特卡洛模拟失败:TypeError - ...",
|
|
||||||
`success` 与 `message` 不再打架。
|
|
||||||
- ~~② `bounds` 形状没校验~~ → 入口加了 `isinstance(bounds, (list, tuple))` 与 `ndim == 2 and shape[1] == 2` 两道;
|
|
||||||
`bounds=[0, 1]` 现在报"bounds 必须形如 [(lo, hi), ...],当前形状为 (2,),请检查是否漏了括号"。
|
|
||||||
- ~~③ `n: int`~~ → 已改 `int | np.integer`。
|
|
||||||
|
|
||||||
### ✅ monte_carlo_integral 的 2 条新口径(2026-10-05 已办 1 条、剩 1 条)
|
|
||||||
|
|
||||||
- ~~① `bounds` 把 ndarray 拒了~~ → 已加 `isinstance(bounds, np.ndarray)` 分支,`np.array([[0.0, 1.0]])` 现在能收。
|
|
||||||
- ~~② `if` / `else` 里那三行重复~~ → 已把 `name` / `params["bounds"]` / `params["dimension"]` 提到 `if` 之前,只剩 `message` 分两支。
|
|
||||||
|
|
||||||
**还剩 1 条(你的活)**
|
|
||||||
1. **第一道 `isinstance` 分支现在是冗余的**:`np.asarray` 本来就同时收 list / tuple / ndarray,
|
|
||||||
而 `[]`、`"abc"`、标量这些都会被下面那句形状校验抓住(报"当前形状为 (0,)"等)。
|
|
||||||
整段可以塌成两行:`bounds_arr = np.asarray(bounds)` + 形状校验 —— 顺带 `bounds.copy()` 也是多余的一次拷贝。
|
|
||||||
|
|
||||||
### ⏳ monte_carlo 只剩 1 个口径要你确认
|
|
||||||
|
|
||||||
**`ci_low` / `ci_high` 是不是就按"结果的分位数"定?**
|
|
||||||
|
|
||||||
现状 `np.percentile(data, [2.5, 97.5])`。实测 n=2e5、E[X²]=1 给出 `[0.001, 5.0455]` ——
|
|
||||||
这是**模拟结果的分散度**(预测区间),与 `p05` / `p95` 同类,只是分位点不同。
|
|
||||||
而"均值估计的置信区间"(MC 的统计误差)是 `mean ± 1.96·std/√n` = `[0.9962, 1.0086]`,
|
|
||||||
两者差两个数量级,论文话术也完全不同:
|
|
||||||
|
|
||||||
- A(现状):写"95% 的模拟结果落在 [a, b]"(描述风险分布)
|
|
||||||
- B:改成均值 CI → 写"估计值 1.0024 ± 0.0043(95% CI)"(证明 n 够大)
|
|
||||||
- C:两者都要 → 指标要改名(如 `p025`/`p975` + `mean_ci_low`/`mean_ci_high`)
|
|
||||||
|
|
||||||
注意:`monte_carlo_probability` 里的 `ci_*` 已经是**估计量的 CI**(Wilson 区间,决策 50),所以这两个函数的 `ci_*`
|
|
||||||
目前**含义不同**——这正是要给它们定口径的原因之一。
|
|
||||||
|
|
||||||
### ✅ 已拍板(决策 48)
|
|
||||||
|
|
||||||
- 批量大小由实现传 `size=n`;`sampler` 必须能接住 `size`,`**kwargs` 里不要再传 `size`。
|
|
||||||
- 结果含 NaN → `success=False`(不落 metrics)。
|
|
||||||
- `std` 用 `ddof=0`。
|
|
||||||
- 出错仍统一返回 `success=False`(`try/except Exception` 保留)。
|
|
||||||
|
|
||||||
### ⏳ 另外两个小口径(你说了算)
|
|
||||||
|
|
||||||
- **`random_state` 的注解**:经验文档第 3 条写的标准是 `int | np.integer | np.random.Generator | None`,
|
|
||||||
但 `stats/bootstrap.py`、`solve/simulate.py`、`stats/inference.py` 现在都写 `int | None`(只有 `core/random.py` 是全的)——
|
|
||||||
要不要全项目对齐?(运行期 `get_rng` 收 `np.int64`,所以是注解与行为不一致,不是功能 bug)
|
|
||||||
- **`std` 的 `ddof`**:`monte_carlo` 用 `ddof=0`,`describe` 那条线用 `ddof=1`。要不要统一?
|
|
||||||
|
|
||||||
### 其余待办
|
|
||||||
|
|
||||||
1. L0 的最后一项是 `save_figure`(**A 档 = 工程**,`viz/export.py`)—— **由 Codex 直接实现**,用户只读。
|
|
||||||
2. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
|
||||||
3. 已拍板的旧口径(留档):`log_transform` 的 `offset` 维持现状(决策 35);
|
|
||||||
`describe` / `zscore` 默认丢掉非数值列(决策 37);变异系数用 `σ / |μ|`(决策 38);
|
|
||||||
分工见决策 39;`correlation_matrix` 走方案 A(决策 41)。
|
|
||||||
4. **仍未答的一个小问题**:`correlation_matrix` 现在只收 `DataFrame`,
|
|
||||||
而 `describe` / `zscore` / `transform` 都收 ndarray —— 要不要也放开?(你说了算)
|
|
||||||
|
|
||||||
5. **`t_test` 有 4 个口径等你拍板(Codex 一律没动,按决策 39)**:
|
|
||||||
- **① 单样本的结论文案说错了。** `b=None` 时 `test_name` 已经是"单样本 t 检验",
|
|
||||||
但 `conclusion` 仍写"两组数据存在统计学上的显著差异" —— 单样本根本没有第二组。
|
|
||||||
建议按分支拼文案(单样本说"该样本均值与 `value` 有显著差异")。
|
|
||||||
- **② `paired=True` 时 NaN 是按各自的位置丢的**,会把不同行配成一对(静默错位,不报错)。
|
|
||||||
两个方向:配对前先**成对删缺失**,或**直接拒绝含 NaN 的配对输入**。你定。
|
|
||||||
- **③ `value` 只在 `b is None` 时生效**,同时传 `b` 和 `value` 会被静默忽略 —— 要不要改成报错?
|
|
||||||
- **④ `t_test(DataFrame)` 会把所有数值列摊平成一个样本**(已写进 `Notes`)。
|
|
||||||
多列不同量纲(`gdp` + `pop`)会被混在一起,而且**没法**拿"一张表里两列"当两组比。
|
|
||||||
要不要改成:多数值列的 DataFrame 直接 `ValueError`、强制先自己取列?
|
|
||||||
|
|
||||||
6. **`as_array` 落地后留下的 3 个小口径(Codex 没代选,按决策 39)**:
|
|
||||||
- `int` / `float` 标量还在白名单里(为兼容 `normalize` / `standardize` / `log_transform`
|
|
||||||
三个 docstring 写的"数值型标量")。但标量进来是 **0 维数组**,`axis=0` 的算法照样炸 ——
|
|
||||||
要不要把标量摘掉、同时改三处 docstring?
|
|
||||||
- 内容转不成 float 时统一抛 `TypeError`(决策 45)—— `normalize(含文本列的表)` 原来抛 `ValueError`。
|
|
||||||
要不要改回 `ValueError`("取值问题")?
|
|
||||||
- 现在 `kind == "frame"` 时你要自己再写一遍 `x.select_dtypes(include=[np.number]).columns` 取列名。
|
|
||||||
嫌烦的话可以让 `as_array` 顺带把选中的列返回(改成三元组),一句话的事。
|
|
||||||
|
|
||||||
7. **`bootstrap_ci` 的 5 个待拍板口径**(Codex 一律没动,按决策 39;详见 `docs/algorithms/bootstrap_ci.md` §10):
|
|
||||||
- `x` 里的 **NaN** 静默传进统计量 → `nan` 区间(`confidence_interval` / `t_test` 都是先丢掉的,口径不齐)
|
|
||||||
- `n_resamples <= 0` → 现在抛 `IndexError: index -1 is out of bounds`(应在入口给可读 `ValueError`)
|
|
||||||
- `confidence` 越界 → 现在由 numpy 抛 `Percentiles must be in the range [0, 100]`
|
|
||||||
- `method="bca"` → docstring 承诺过、实测 `NotImplementedError`(补实现 ≈25 行,还是从 docstring 删掉?)
|
|
||||||
- `bootstrap_statistic` 仍是 `NotImplementedError`,而 `bootstrap_ci` 把那段重采样循环**内联**了 ——
|
|
||||||
建议抽出去让 `bootstrap_ci` 调它(易错点 16 的变体:同一段算法写了两遍,只实现了一遍)
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,207 +0,0 @@
|
|||||||
# ChatGPT 建库建议 · 审查与精简
|
|
||||||
|
|
||||||
> 被审查对象:仓库根目录 `chatGPT_Suggestion.md`(33 节,约 2.4 万字)
|
|
||||||
> 审查日期:2026-10-01
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、总裁决
|
|
||||||
|
|
||||||
**方向对,量级错。**
|
|
||||||
|
|
||||||
**方向对的地方(保留)**:把比赛的即用代码沉淀成"竞赛层薄封装 + 高频算法自己实现 + 统一结果接口",而不是攒一堆题解。这个定位是全文最有价值的部分,也是这个仓库存在的理由。
|
|
||||||
|
|
||||||
**量级错的地方(砍)**:原文给出 14 个子包、约 250 个函数、每个函数强制 10 项文档。这是一个人 **6~12 个月**的个人项目体量,不是支撑 **4 天**比赛的体量。按原文执行,最可能的结局是:前两周写完 `core` 和 `data`,然后在 `optimization/` 的 6 个文件面前失去动力,仓库停在半成品——这比"只有一个 20 个函数的 `mcm_toolkit.py`"更糟,因为半成品既不能用于比赛,也不好看。
|
|
||||||
|
|
||||||
**精简后的目标**:
|
|
||||||
|
|
||||||
| 维度 | ChatGPT 版 | 精简版 |
|
|
||||||
|---|---|---|
|
|
||||||
| 子包 | 14 | **7** |
|
|
||||||
| 函数(v0.1) | ~250 | **~40**(其中第一周只做 12 个) |
|
|
||||||
| Result 类 | 5 | **1** |
|
|
||||||
| 单函数文档要求 | 10 项,全体强制 | **2 档**,只有 ~15 个"招牌算法"做全卡片 |
|
|
||||||
| 预计完成周期 | —— | **4~6 周**出可用 v0.1 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、必须保留的(原文的干货)
|
|
||||||
|
|
||||||
| # | 原文主张 | 为什么留 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | 「不要重新造轮子。`mcm_toolkit` 做竞赛层封装,而不是替代 SciPy/sklearn/NetworkX」 | 全文最重要的一句。它同时是设计原则和**防膨胀闸门** |
|
|
||||||
| 2 | 统一结果对象(`result.params / metrics / success`) | 真实痛点。比赛时最烦的就是每个函数返回不一样的东西 |
|
|
||||||
| 3 | 依赖分层:核心 4 个包 + `[project.optional-dependencies]` | 符合轻量化,且让 CI 和赛场安装都能可控 |
|
|
||||||
| 4 | 顶层 `__init__` 不 `import *`,只导出子包 + 少量高频快捷函数 | 保留 |
|
|
||||||
| 5 | 「`__all__` 是**公开 API 清单**,不是导出按钮」 | 保留,这条纪律要从第一版养成 |
|
|
||||||
| 6 | `problems/` 沉淀历年真题、`notebooks/` 做探索 | "每做一题反向沉淀一个通用函数"是这套东西能长大的唯一机制 |
|
|
||||||
| 7 | `save_figure(fig, path, dpi=300, formats=("png","pdf"))` | 美赛送稿硬需求,原文提了但严重低估——见第四节 |
|
|
||||||
| 8 | 参考库定位:SALib / NetworkX / statsmodels / pymoo / SymPy / sklearn | 六条定位全部准确,可直接当"别自己写"的白名单 |
|
|
||||||
| 9 | A / B / C 三级(自己写 / 自己封装 / 直接调用)的分类框架 | 框架留,**边界要下调**(见第三节第 10 条) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、要砍的
|
|
||||||
|
|
||||||
### 3.1 结构层:14 包 → 7 包
|
|
||||||
|
|
||||||
| 原文的包 | 处置 | 理由 |
|
|
||||||
|---|---|---|
|
|
||||||
| `core/` | **留**(4 → 3 文件) | 基础设施,必须有 |
|
|
||||||
| `data/` | **留**(4 文件) | 最高频 |
|
|
||||||
| `statistics/` | **留**,改名 `stats/`(5 → 3 文件) | S 级模块 |
|
|
||||||
| `regression/` + `ml/` | **合并为 `models/`** | 原文这两块 `fit_linear_regression` 与 `fit_linear` 明显重复,一套 `fit/predict/score` 协议就够了 |
|
|
||||||
| `optimization/` + `simulation/` + `sensitivity/` | **合并为 `solve/`** | 这三者是同一件事的三种形态:"把模型反复算"。分开建三个包,共享的 `Result`、`rng`、并行约定要在三处重复 |
|
|
||||||
| `dynamics/` | **降级**为 `solve/dynamics.py`,进 v0.2 | 原文自己承认"不一定主攻 MCM A"。MCM A 一年一题,且 SymPy+SciPy 封装只需 4 个函数,不值得一个独立包 |
|
|
||||||
| `network/` | **降级**为 `solve/graph.py`,进 v0.2 | 只有 ICM D 用;NetworkX 已全部提供。原文列了 5 个子模块 20+ 函数,其中 `degree_centrality` 之类就是一行转发 |
|
|
||||||
| `decision/` | **留但砍半**:8 → 4 文件 | AHP / TOPSIS / 熵权 / 灰色是中文赛高频,值得留;`dea`、`fuzzy` 见下 |
|
|
||||||
| `visualization/` | **改 `viz/`**,13 个 `plot_*` → 1 个 `plot(kind=...)` + 3 个专用图 | 见 3.3 |
|
|
||||||
| `utils/metrics.py` | **取消**,指标并入 `models/metrics.py` | 原文第 26 节又建议单列 `analysis/` 放同样的函数,三处重复 |
|
|
||||||
| 第 26 节的 `analysis/` | **取消** | 与 `utils/metrics.py` 功能完全重叠 |
|
|
||||||
| `docs/api/` `docs/tutorials/` | 保留占位,**不写内容** | 自动 API 文档用 mkdocs/sphinx 生成,手写必然过期 |
|
|
||||||
|
|
||||||
### 3.2 设计层
|
|
||||||
|
|
||||||
| 砍什么 | 理由 | 替代 |
|
|
||||||
|---|---|---|
|
|
||||||
| 5 个 Result 类 | 第一版没有任何一个函数需要区分这五种;过早分类会导致大量样板代码 | **1 个 `Result`**:`name / params / metrics / data / success / message`,后续真需要时再派生 |
|
|
||||||
| 每个函数 10 项文档(数学定义/适用场景/参数/实现/示例/测试/复杂度/数值稳定性/常见错误/MCM 案例) | 对一个一行转发的封装函数写 10 项文档,写作成本是实现的 20 倍。**这是最可能导致项目停滞的一条** | 两档制,见第七节 |
|
|
||||||
| 「`__init__.py` 里再导出少量高频函数」的具体选择 | 原文选的是 `solve_ode`——但 ODE 已被降级 | 改成 `monte_carlo / bootstrap_ci / minimize / parameter_sweep / topsis / compare_models` |
|
|
||||||
|
|
||||||
### 3.3 函数层:要删的清单
|
|
||||||
|
|
||||||
> 判断标准只有两条:**美赛近 10 年题面出现过吗?** / **成熟库是不是一行就够?** 两条都不满足就删。
|
|
||||||
|
|
||||||
| 删除项 | 理由 | 替代 |
|
|
||||||
|---|---|---|
|
|
||||||
| `maximize()` | 就是 `minimize` 取负 | `minimize` 加 `sense="max"` 参数 |
|
|
||||||
| `kendall_correlation` / `partial_correlation` | 美赛几乎不用;`df.corr(method="kendall")` 一行 | pandas |
|
|
||||||
| `modified_zscore_outliers` / `zscore_outliers` | IQR 和 z-score 两种覆盖 95% 场景 | 只留 `detect_outliers(method="iqr"|"zscore")` |
|
|
||||||
| `distribution_pdf/cdf/ppf` 三个独立函数 | 纯转发 scipy | `fit_distribution` 返回的对象上挂 `.pdf/.cdf/.ppf` |
|
|
||||||
| `sample_without_replacement` / `sample_with_replacement` | `rng.choice(..., replace=)` 一行 | numpy |
|
|
||||||
| `brownian_motion` / `geometric_brownian_motion` | niche,只有金融题偶尔用 | 用到再写;`random_walk` 已覆盖 |
|
|
||||||
| `mmc_metrics` / `simulate_queue` | 排队论在美赛出现频率极低,且 `simulate_queue` 是个完整的离散事件仿真引擎,成本极高 | 只留 `mm1_metrics`(闭式解,10 行) |
|
|
||||||
| `crowding_distance` / `hypervolume` | NSGA-II 内部件,属于原文自己划的 C 类 | pymoo |
|
|
||||||
| `dea_efficiency` | 低频;且要解一堆 LP,实现成本不低 | 真要用,现场用 `scipy.optimize.linprog` 拼 |
|
|
||||||
| `fuzzy_comprehensive_evaluation` | 低频,且"隶属度函数"没有通用形式,做不了通用封装 | 删 |
|
|
||||||
| `value_iteration` + `policy_iteration` + `dynamic_programming` 三个 | 三者是同一族的三种写法,第一版同时做属于过度设计 | 只留 `dynamic_programming`(通用)+ `knapsack_dp`(模板) |
|
|
||||||
| `weighted_moving_average` / `holt_trend` / `seasonal_decompose` | statsmodels 一行。原文自己也在第 7 节说"做薄封装" | 只留 `exponential_smoothing` 一个包装 |
|
|
||||||
| `grid_sweep` | `parameter_sweep` 的特例 | 合入 `parameter_sweep` |
|
|
||||||
| 13 个 `plot_*` | 每个都是 `ax` 上加两行;拆成 13 个函数后,改一次全局样式要改 13 处 | `plot(x, y, kind="line"|"scatter"|"hist"|"box"|"heatmap")` + `plot_correlation_matrix` / `plot_residuals` / `plot_convergence` 三个有真实逻辑的 |
|
|
||||||
| `normalize` 与 `minmax_scale` 并存 | 是同一个东西的两个名字 | 只留 `normalize` |
|
|
||||||
| `standardize` 与 `zscore` 并存(一个在 data、一个在 stats) | 同上 | 只留 `data.standardize` |
|
|
||||||
| `higher-level`:`sample_distribution`、`distribution_pdf` 系列 | 转发 | 删 |
|
|
||||||
|
|
||||||
**净效果:约 250 → 约 40 个函数。**
|
|
||||||
|
|
||||||
### 3.4 下调 A/B/C 的边界
|
|
||||||
|
|
||||||
原文的 A 类("强烈建议自己实现")里有 `dynamic programming`、`genetic algorithm`、`sensitivity analysis` 等。**用于学习时**这个清单没问题;但**比赛现场**要反过来:
|
|
||||||
|
|
||||||
- 比赛时:**能用库就用库**(SALib 的 `sobol_analyze` 一定比自己写的对)。
|
|
||||||
- 平时:自己实现,目的是"读得懂、改得动、能解释给队友"。
|
|
||||||
- 所以自己实现的代码要单独标 `_from_scratch` 或放在 `mcm_toolkit/algo/`,默认走库版本。
|
|
||||||
|
|
||||||
原文把这两个场景混在一起了。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、ChatGPT 漏掉的(真正决定比赛成败的)
|
|
||||||
|
|
||||||
原文 33 节几乎全是"代码结构",但**美赛是论文比赛,不是代码比赛**。以下六条原文完全没提或严重低估:
|
|
||||||
|
|
||||||
1. **论文产出链路(最严重的缺失)**
|
|
||||||
工具箱的最终消费者是**论文**。原文只给了一个 `save_figure`。至少要补:
|
|
||||||
- `save_table(df, path, fmt=("csv","md","latex"))` —— 结果表直接出 LaTeX,粘贴进论文
|
|
||||||
- 全局图样式 `mcm_style()`:字号 ≥ 9pt、线宽 ≥ 1.5、`dpi=300`、色盲友好调色板、双栏宽度
|
|
||||||
- 图注(`fig.caption`)与"图号—正文引用"的约定
|
|
||||||
|
|
||||||
2. **量纲与方向**
|
|
||||||
`normalize` 没区分"无量纲化"和"标准化",更没提**效益型 / 成本型指标方向**。评价类题目(TOPSIS/熵权)翻车基本都翻在这里。
|
|
||||||
|
|
||||||
3. **时间预算**
|
|
||||||
4 天比赛,函数的价值 = 正确性 ÷ 上手时间。原文没有一条关于"够快"的约定:MC 必须向量化、`n_resamples` 默认值要能在一分钟内跑完、不要引入编译开销。
|
|
||||||
|
|
||||||
4. **可复现性**
|
|
||||||
提了 `get_rng`,但没提"提交要能复现"。约定:所有带随机的公开函数第一个参数是 `random_state`,论文附录必须写 seed。
|
|
||||||
|
|
||||||
5. **测试测什么**
|
|
||||||
说了 pytest,没说测什么。应测 `已知解析解`:MC 积分 vs 解析积分、正态 CI vs 公式值、`bootstrap_ci` 大样本下收敛到 CLT 值。**不要测实现细节**。
|
|
||||||
|
|
||||||
6. **离线约束**
|
|
||||||
原文的 extras 设计假设比赛机器能联网。现场装不上包是真实风险,必须有 `environment.yml` 锁版本 + 一份"最小可用依赖"清单。
|
|
||||||
|
|
||||||
另外两条工程问题:
|
|
||||||
|
|
||||||
7. **原文第 32 节把 `problems/` `notebooks/` 和 `src/` 混在一个仓库**——这是对的,但要注意 `problems/` 会带来大量数据文件,`.gitignore` 要提前写好(见仓库根目录的 `.gitignore`)。
|
|
||||||
8. **没有"不做"清单**。原文用"建议不要",但真正能防膨胀的是写死的"本仓库**永远不**实现 X"。已补进 `docs/03-roadmap.md`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、精简后的结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
MCM&ICM_Toolkit/
|
|
||||||
├── src/mcm_toolkit/
|
|
||||||
│ ├── core/ 基础设施:Result / rng / 校验 ← 第一个做
|
|
||||||
│ ├── data/ 读、洗、变换、采样 ← 最高频
|
|
||||||
│ ├── stats/ 描述、推断、bootstrap ← S 级
|
|
||||||
│ ├── models/ 回归 + 时序 + ML 薄封装(统一 fit/predict/score)
|
|
||||||
│ ├── solve/ 优化、模拟、敏感性、图、动力学 ← 竞赛主力
|
|
||||||
│ ├── decision/ 熵权 / AHP / TOPSIS / 灰色
|
|
||||||
│ └── viz/ 统一绘图 + 图表导出
|
|
||||||
├── tests/ tests/test_*.py(按包一一对应)
|
|
||||||
├── examples/ 每个模块一个能跑的最小例子
|
|
||||||
├── problems/ 历年真题(2025_C/ ...)
|
|
||||||
├── notebooks/ 探索用
|
|
||||||
├── docs/ 本目录
|
|
||||||
├── scripts/ setup-conda.ps1 / setup-venv.ps1
|
|
||||||
├── pyproject.toml
|
|
||||||
├── environment.yml
|
|
||||||
└── STATUS.md 进度
|
|
||||||
```
|
|
||||||
|
|
||||||
包结构详情与每个包的边界见 `02-architecture.md`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、功能清单分级
|
|
||||||
|
|
||||||
见 `03-roadmap.md`。摘要:
|
|
||||||
|
|
||||||
- **L0(第一周,12 个)**:`Result` `get_rng` `load_table` `clean_missing` `detect_outliers` `normalize` `standardize` `describe` `correlation_matrix` `bootstrap_ci` `monte_carlo` `save_figure`
|
|
||||||
- **L1(v0.1,共约 40 个)**:L0 + 回归/时序/优化/敏感性/评价/绘图
|
|
||||||
- **L2(v0.2)**:`dynamics`、`graph`
|
|
||||||
- **永久不做**:见 `03-roadmap.md` 末节
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、开发纪律:文档从 10 项降到 2 档
|
|
||||||
|
|
||||||
原文要求**每个**函数都写 10 项。改成两档:
|
|
||||||
|
|
||||||
**S 档(招牌算法,约 15 个)** —— 用 `docs/dev/function-card-template.md` 的完整卡片:
|
|
||||||
数学定义 / 适用场景 / 参数 / 复杂度 / 数值稳定性 / 常见错误 / 最小示例 / 单测 / 赛题用法。
|
|
||||||
覆盖:`bootstrap_ci` `monte_carlo` `simulated_annealing` `dynamic_programming` `parameter_sweep` `sobol_analyze` `topsis` `entropy_weights` `ahp_weights` `gm11` `detect_outliers` `local_sensitivity` 等。
|
|
||||||
|
|
||||||
**A 档(其余,薄封装)** —— 只要求:
|
|
||||||
1. docstring 里**一句话数学含义 + 一句话什么时候用**;
|
|
||||||
2. 一条 `pytest`(对齐库结果或已知值)。
|
|
||||||
|
|
||||||
不单独写文档。
|
|
||||||
|
|
||||||
> 判断标准:**我会不会去改它的内部实现?** 会 → S 档;只当转发器用 → A 档。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、执行顺序(时间盒)
|
|
||||||
|
|
||||||
| 周 | 目标 | 交付 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | `core` + `data` + `stats` 骨架;S 档先写 `bootstrap_ci` `monte_carlo` `normalize` `detect_outliers` | L0 全部可跑通 |
|
|
||||||
| 2–3 | `models` + `solve/optimize` + `solve/simulate` | `compare_models` 能跑 |
|
|
||||||
| 4 | `solve/sensitivity` + `decision` + `viz` | 一套端到端 example |
|
|
||||||
| 5–6 | 拿一道真题(如 2025 C)从头走一遍,反向补函数 | `problems/2025_C/` 完成 |
|
|
||||||
| 之后 | 每做一题 → 沉淀 1~3 个函数 + 对应 test | 仓库自然长大 |
|
|
||||||
|
|
||||||
**这个顺序的关键是"先能跑通一条完整链路,再补宽度"**,与原文"先把 14 个包建齐"相反。
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
# 架构设计
|
|
||||||
|
|
||||||
> 上游依据:`01-suggestion-review.md`。本文件定义"代码往哪写、边界在哪、接口长什么样"。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、设计原则
|
|
||||||
|
|
||||||
1. **薄封装优先。** 能用 SciPy / sklearn / statsmodels / NetworkX / SALib 一行解决的,就只做一行转发 + 参数校验 + `Result` 包装。本仓库**不**替代这些库。
|
|
||||||
2. **自己实现是有目的的。** 只有"库里没有、或者自己写才学得会"的算法才手写(bootstrap、Monte Carlo、DP、SA、熵权、TOPSIS、灰色……)。手写实现要能被读懂、能被改。
|
|
||||||
3. **统一出口。** 凡是"拟合 / 求解 / 模拟"类函数,一律返回 `Result`,字段固定。
|
|
||||||
4. **可复现。** 所有含随机的公开函数都接受 `random_state`,默认 `None`(用全局 RNG)。
|
|
||||||
5. **默认值面向比赛。** 默认参数应当在 4 天赛程里"直接能跑完",不用调。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、包结构(7 个包)
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/mcm_toolkit/
|
|
||||||
├── __init__.py 只导出 6 个高频快捷函数 + 7 个子包
|
|
||||||
├── _version.py
|
|
||||||
│
|
|
||||||
├── core/ ① 基础设施 —— 所有包的地基
|
|
||||||
│ ├── result.py Result
|
|
||||||
│ ├── random.py set_seed / get_rng
|
|
||||||
│ └── validate.py as_array(统一入口)/ check_array / check_same_length / check_probability / check_square
|
|
||||||
│
|
|
||||||
├── data/ ② 数据进出 —— 最高频
|
|
||||||
│ ├── io.py load_table / save_table
|
|
||||||
│ ├── clean.py clean_missing / impute_missing / detect_outliers / winsorize
|
|
||||||
│ ├── transform.py normalize / standardize / log_transform / one_hot_encode
|
|
||||||
│ └── sample.py train_test_split / kfold_split / bootstrap_sample
|
|
||||||
│
|
|
||||||
├── stats/ ③ 统计 —— S 级模块
|
|
||||||
│ ├── descriptive.py describe / zscore / coefficient_of_variation
|
|
||||||
│ ├── inference.py confidence_interval / t_test / anova / chi_square_test
|
|
||||||
│ │ fit_distribution / goodness_of_fit
|
|
||||||
│ └── bootstrap.py bootstrap_statistic / bootstrap_ci ★ 自己实现
|
|
||||||
│
|
|
||||||
├── models/ ④ 建模 —— 回归 / 时序 / ML,统一协议
|
|
||||||
│ ├── base.py Model 协议:fit / predict / score
|
|
||||||
│ ├── metrics.py rmse / mae / mape / r2_score / adjusted_r2 / compare_models
|
|
||||||
│ ├── regression.py fit_linear / fit_polynomial / fit_nonlinear / predict
|
|
||||||
│ ├── timeseries.py exponential_smoothing / fit_arima / forecast
|
|
||||||
│ └── ml.py fit_forest / fit_logistic / kmeans / pca / cross_validate
|
|
||||||
│
|
|
||||||
├── solve/ ⑤ 求解 —— 竞赛主力(把模型反复算)
|
|
||||||
│ ├── optimize.py minimize / linear_program / integer_program
|
|
||||||
│ │ dynamic_programming / knapsack_dp ★ 自己实现
|
|
||||||
│ │ simulated_annealing ★ 自己实现
|
|
||||||
│ │ pareto_front
|
|
||||||
│ ├── simulate.py monte_carlo / monte_carlo_probability / monte_carlo_integral
|
|
||||||
│ │ random_walk / markov_simulate / markov_steady_state
|
|
||||||
│ │ mm1_metrics
|
|
||||||
│ ├── sensitivity.py local_sensitivity / one_at_a_time / parameter_sweep
|
|
||||||
│ │ robustness_test / sobol_analyze / morris_analyze
|
|
||||||
│ ├── graph.py 【v0.2】build_graph / shortest_path / centrality / pagerank / max_flow
|
|
||||||
│ └── dynamics.py 【v0.2】solve_ode / solve_system / find_equilibrium / local_stability
|
|
||||||
│
|
|
||||||
├── decision/ ⑥ 评价决策 —— 中文赛特色
|
|
||||||
│ ├── weighting.py entropy_weights / critic_weights / equal_weights
|
|
||||||
│ ├── ahp.py ahp_weights / ahp_consistency_ratio
|
|
||||||
│ ├── topsis.py topsis
|
|
||||||
│ └── grey.py grey_relational_analysis / gm11_fit_predict
|
|
||||||
│
|
|
||||||
└── viz/ ⑦ 出图出表 —— 论文产出的最后一公里
|
|
||||||
├── style.py mcm_style
|
|
||||||
├── plot.py plot / plot_correlation_matrix / plot_residuals / plot_convergence
|
|
||||||
└── export.py save_figure / save_table
|
|
||||||
```
|
|
||||||
|
|
||||||
★ = 自己实现(S 档,配完整函数卡片)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、与 ChatGPT 原方案的对照
|
|
||||||
|
|
||||||
| 原方案 | 现方案 | 变更理由 |
|
|
||||||
|---|---|---|
|
|
||||||
| 14 个包 | 7 个包 | 见 `01-suggestion-review.md` §3.1 |
|
|
||||||
| `regression/` + `ml/` | 合并 `models/` | 原方案里 `fit_linear` 与 `fit_linear_regression` 重复 |
|
|
||||||
| `optimization/` + `simulation/` + `sensitivity/` | 合并 `solve/` | 三者共享 Result / rng / 并行约定,分开会重复三遍 |
|
|
||||||
| `dynamics/` `network/` | 降级为 `solve/dynamics.py` `solve/graph.py`,进 v0.2 | 低频,不值得独立包 |
|
|
||||||
| `utils/metrics.py` + 单列 `analysis/` | 合并 `models/metrics.py` | 原方案三处重复放同一批指标 |
|
|
||||||
| 13 个 `plot_*` | `plot(kind=...)` + 3 个专用图 | 改全局样式不必改 13 处 |
|
|
||||||
| 5 个 Result 类 | 1 个 `Result` | 第一版没有一个函数需要区分五种结果 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、关键约定
|
|
||||||
|
|
||||||
### 4.1 `Result`(唯一结果对象)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class Result:
|
|
||||||
name: str # 产生它的函数名
|
|
||||||
params: dict | None = None # 参数估计 / 决策变量取值
|
|
||||||
metrics: dict | None = None # 评估指标
|
|
||||||
data: Any = None # 主要载荷(预测值 / 轨迹 / 排序……)
|
|
||||||
success: bool = True
|
|
||||||
message: str = ""
|
|
||||||
```
|
|
||||||
|
|
||||||
约定:
|
|
||||||
- 任何"拟合 / 求解 / 模拟 / 评价"函数**只返回 `Result`**,不返回裸 tuple。
|
|
||||||
- `Result` 支持 `repr` 直接打印成"指标表",方便贴进论文草稿。
|
|
||||||
- 后续真需要区分类型时再派生,不提前建 5 个类。
|
|
||||||
|
|
||||||
### 4.2 随机数
|
|
||||||
|
|
||||||
```python
|
|
||||||
set_seed(seed) -> None # 全局种子
|
|
||||||
get_rng(random_state=None) -> np.random.Generator
|
|
||||||
```
|
|
||||||
|
|
||||||
- 所有含随机的函数签名统一含 `random_state=None`,内部第一行 `rng = get_rng(random_state)`。
|
|
||||||
- **禁止**直接用 `np.random.*` 旧接口,禁止裸用 `random` 模块。
|
|
||||||
- 论文附录必须写明 seed。
|
|
||||||
|
|
||||||
### 4.3 模型协议
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Model(Protocol):
|
|
||||||
def fit(self, X, y, **kw) -> "Model": ...
|
|
||||||
def predict(self, X) -> np.ndarray: ...
|
|
||||||
def score(self, X, y) -> dict: ... # 返回指标 dict,不是单个 float
|
|
||||||
```
|
|
||||||
|
|
||||||
- 这样 `compare_models({"linear": m1, "rf": m2}, X, y)` 才能统一执行。
|
|
||||||
- `compare_models` 返回 `pd.DataFrame`(行=模型,列=指标),可直接 `save_table(..., fmt="latex")`。
|
|
||||||
|
|
||||||
### 4.4 指标方向与量纲(评价类必读)
|
|
||||||
|
|
||||||
- `normalize` 只做**无量纲化**(min-max / 向量归一),不改变指标方向。
|
|
||||||
- 方向(效益型 / 成本型)在 `decision` 层处理,用参数 `benefit_columns` 显式声明。
|
|
||||||
- **不允许**默认假设"越大越好"。
|
|
||||||
|
|
||||||
### 4.5 import 规则
|
|
||||||
|
|
||||||
- 顶层 `mcm_toolkit/__init__.py` 只导出子包 + 6 个快捷函数,**不 `import *`**。
|
|
||||||
- 子包之间允许单向依赖:`core ← data / stats / models / solve / decision / viz`;`viz` 可依赖其余全部;**禁止反向依赖**。
|
|
||||||
- `__all__` 是公开 API 清单,不写"内部工具"。
|
|
||||||
|
|
||||||
### 4.6 依赖分层
|
|
||||||
|
|
||||||
| 层 | 包 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| 核心(必装) | `numpy` `scipy` `pandas` `matplotlib` | `core` `data` `stats` `viz` 只需要这些 |
|
|
||||||
| `[ml]` | `scikit-learn` `statsmodels` | `models/` |
|
|
||||||
| `[graph]` | `networkx` | `solve/graph.py` |
|
|
||||||
| `[sensitivity]` | `SALib` | `sobol_analyze` / `morris_analyze` |
|
|
||||||
| `[symbolic]` | `sympy` | `solve/dynamics.py` |
|
|
||||||
| `[moo]` | `pymoo` | 真需要 NSGA-II 时 |
|
|
||||||
| `[excel]` | `openpyxl` | Excel 读写 |
|
|
||||||
| `[dev]` | `pytest` `ruff` `pyright` | 开发 |
|
|
||||||
|
|
||||||
**可选依赖缺失时必须给出可读报错**,例如 `sobol_analyze` 在没装 SALib 时提示 `pip install "mcm-toolkit[sensitivity]"`,而不是 `ModuleNotFoundError` 裸抛。
|
|
||||||
|
|
||||||
|
|
||||||
### 4.7 类型约定(每个函数都适用)
|
|
||||||
|
|
||||||
公开函数的**参数类型注解必须写全**,并且与 docstring 说的是同一件事:注解给 IDE / pyright 看,
|
|
||||||
docstring 给人看。两处打架时,合法用法会被标红,真正的错误反而看不见。
|
|
||||||
|
|
||||||
判断顺序照抄下表,**不要凭直觉**:
|
|
||||||
|
|
||||||
| 值可能来自 | 怎么写 | 为什么 |
|
|
||||||
|---|---|---|
|
|
||||||
| 整数 | `isinstance(x, (int, np.integer))`,取值统一 `int(x)` | `np.int64` **不是** `int` 的子类,只写 `int` 会漏 |
|
|
||||||
| 浮点 | `isinstance(x, (float, np.floating))` | `np.float64` 是 `float` 子类,但 `np.float32` 不是——不统一 |
|
|
||||||
| 布尔 | `isinstance(x, bool)` 要放在 `int` 判断**之前** | `True` 也是 `int`,顺序反了会当成 1 |
|
|
||||||
| 数组 / 表 | `np.asarray(x, dtype=float)` 后再校验 | 别假设调用方已经转好类型 |
|
|
||||||
|
|
||||||
**非法类型必须 `raise TypeError`,不许静默兜底。** 静默的典型后果是
|
|
||||||
"种子被忽略、结果不可复现,但没有任何提示"——比直接报错难查一百倍。
|
|
||||||
|
|
||||||
配套要求,每个公开函数都要有:
|
|
||||||
|
|
||||||
- `Parameters` 写清类型与含义;允许 `None` 时,写明 `None` 会走哪条分支
|
|
||||||
- `Raises` 写清会抛什么异常
|
|
||||||
|
|
||||||
参考实现:`core/random.py` 的 `get_rng`。
|
|
||||||
|
|
||||||
### 4.8 入口只有一个:`core.validate.as_array`
|
|
||||||
|
|
||||||
**凡是要数值数组的函数,第一行都从 `as_array` 进**,不要各自抄
|
|
||||||
`isinstance(x, pd.DataFrame)` / `select_dtypes` / `np.asarray`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
arr, kind = as_array(x, numeric_only=False) # 入口:白名单 + 转 ndarray + 报告原类型
|
|
||||||
... # 中间只写算法
|
|
||||||
if kind == "frame": # 出口:自己手写,各函数形状不同,没法一刀切
|
|
||||||
return pd.DataFrame(result, index=x.index, columns=x.columns)
|
|
||||||
if kind == "series":
|
|
||||||
return pd.Series(result, index=x.index, name=x.name)
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
三条硬规矩:
|
|
||||||
|
|
||||||
1. **`numeric_only` 按"输出契约"选,不按喜好选**:输出要与输入**同形**的函数
|
|
||||||
(`normalize` / `standardize` / `log_transform`)必须 `False` —— 跳过非数值列会让返回的表少列、
|
|
||||||
对不上原表;输出是"一张数值结果表"的(`zscore` / `confidence_interval` / `t_test`)用默认的 `True`。
|
|
||||||
2. **默认不摊平**。要摊平必须显式 `ndim=1`(`t_test` 曾因静默摊平把 `gdp` 和 `pop` 混成一个样本)。
|
|
||||||
3. **`as_array` 不删缺失值**。`df.corr` 成对删、`describe` 整列统计、`paired=True` 的 t 检验必须**成对**删 ——
|
|
||||||
在这一层删掉,行与行的对应关系就永久丢了。删缺失是**语义**,归各函数。
|
|
||||||
|
|
||||||
只走入口、不走出口的(输出与输入不同形):`describe`、`correlation_*`、`confidence_interval`、`t_test`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、测试策略
|
|
||||||
|
|
||||||
- 目录 `tests/` 与包一一对应(`tests/test_stats.py` ↔ `stats/`)。
|
|
||||||
- **测已知解析解,不测实现细节**:
|
|
||||||
|
|
||||||
| 函数 | 测法 |
|
|
||||||
|---|---|
|
|
||||||
| `monte_carlo_integral` | 对 `∫₀¹ x² dx = 1/3` 收敛,误差 < 1e-2(n=1e5) |
|
|
||||||
| `bootstrap_ci` | 正态样本大 n 下,百分位 CI ≈ 解析 CI(±5%) |
|
|
||||||
| `confidence_interval` | 与 `scipy.stats.norm.interval` 对齐 |
|
|
||||||
| `fit_polynomial` | 与 `np.polyfit` 系数对齐(`allclose`) |
|
|
||||||
| `detect_outliers` | 人工构造带 3 个离群点的数组,命中数 = 3 |
|
|
||||||
| `topsis` | 与手算的排序一致 |
|
|
||||||
|
|
||||||
- 随机相关测试一律固定 `random_state`。
|
|
||||||
- 每条断言都要能说清"错了说明什么"。
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# 路线图与开发纪律
|
|
||||||
|
|
||||||
> 依据:`01-suggestion-review.md` §6–§8。
|
|
||||||
> 进度看根目录 `STATUS.md`,本文件只定义"做什么、做到什么程度、什么不做"。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、功能清单分级
|
|
||||||
|
|
||||||
### L0 —— 第一周必做(12 个)
|
|
||||||
|
|
||||||
没有这 12 个,工具箱根本跑不起来;有了这 12 个,已经能应付大部分题目。
|
|
||||||
|
|
||||||
| 函数 | 所在模块 | 档位 |
|
|
||||||
|---|---|---|
|
|
||||||
| `Result` | `core/result.py` | A |
|
|
||||||
| `get_rng` / `set_seed` | `core/random.py` | A |
|
|
||||||
| `load_table` | `data/io.py` | A |
|
|
||||||
| `clean_missing` | `data/clean.py` | A |
|
|
||||||
| `detect_outliers` | `data/clean.py` | **S** |
|
|
||||||
| `normalize` | `data/transform.py` | A |
|
|
||||||
| `standardize` | `data/transform.py` | A |
|
|
||||||
| `describe` | `stats/descriptive.py` | A |
|
|
||||||
| `correlation_matrix` | `stats/inference.py` | A |
|
|
||||||
| `bootstrap_ci` | `stats/bootstrap.py` | **S** |
|
|
||||||
| `monte_carlo` | `solve/simulate.py` | **S** |
|
|
||||||
| `save_figure` | `viz/export.py` | A |
|
|
||||||
|
|
||||||
> `correlation_matrix` 有两个**伴随函数**(决策 41):`correlation_pvalues` 给 p 值矩阵、
|
|
||||||
> `correlation_table` 给"系数 + 星标"的论文表格。三者共用同一套入参守卫与**成对删缺失**口径。
|
|
||||||
|
|
||||||
### L1 —— v0.1(共约 40 个)
|
|
||||||
|
|
||||||
在 L0 基础上补齐:
|
|
||||||
|
|
||||||
| 模块 | 函数 |
|
|
||||||
|---|---|
|
|
||||||
| `core` | `check_array` `check_same_length` `check_probability` `check_square_matrix` |
|
|
||||||
| `data` | `impute_missing` `winsorize` `log_transform` `one_hot_encode` `train_test_split` `kfold_split` `bootstrap_sample` |
|
|
||||||
|
|
||||||
> `core.validate.as_array`(**统一入口**,决策 44)已提前落地。它不是"多一个公开 API",
|
|
||||||
| `stats` | `zscore` `coefficient_of_variation` `confidence_interval` `t_test` `anova` `chi_square_test` `fit_distribution` `goodness_of_fit` `bootstrap_statistic` |
|
|
||||||
| `models` | `rmse` `mae` `mape` `smape` `r2_score` `adjusted_r2` `compare_models` `fit_linear` `fit_polynomial` `fit_nonlinear` `predict` `exponential_smoothing` `fit_arima` `forecast` `fit_forest` `fit_logistic` `kmeans` `pca` `cross_validate` |
|
|
||||||
| `solve` | `minimize` `linear_program` `integer_program` `dynamic_programming` `knapsack_dp` `simulated_annealing` `pareto_front` `monte_carlo_probability` `monte_carlo_integral` `random_walk` `markov_simulate` `markov_steady_state` `mm1_metrics` `local_sensitivity` `one_at_a_time` `parameter_sweep` `robustness_test` `sobol_analyze` `morris_analyze` |
|
|
||||||
| `decision` | `equal_weights` `entropy_weights` `critic_weights` `ahp_weights` `ahp_consistency_ratio` `topsis` `grey_relational_analysis` `gm11_fit_predict` |
|
|
||||||
| `viz` | `mcm_style` `plot` `plot_correlation_matrix` `plot_residuals` `plot_convergence` `save_table` |
|
|
||||||
|
|
||||||
### L2 —— v0.2
|
|
||||||
|
|
||||||
- `solve/graph.py`:`build_graph` `shortest_path` `min_spanning_tree` `betweenness_centrality` `pagerank` `max_flow` `community_detection`
|
|
||||||
- `solve/dynamics.py`:`solve_ode` `solve_system` `find_equilibrium` `local_stability`
|
|
||||||
|
|
||||||
**触发条件**:在真题里真的用到了,或者要为 MCM A / ICM D 备赛时再写。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、永久不做(防膨胀清单)
|
|
||||||
|
|
||||||
> 原文用"建议不要",这里写成**硬规则**。往这个清单里加东西,必须先改这份文档。
|
|
||||||
|
|
||||||
- 不重写 SciPy 的优化器、`scipy.stats` 的分布、`scipy.integrate` 的积分器
|
|
||||||
- 不重写 sklearn 的 estimator(`fit/predict` 之外一律不做)
|
|
||||||
- 不重写 statsmodels 的时间序列框架
|
|
||||||
- 不重写 NetworkX 的图算法
|
|
||||||
- 不重写 SALib 的 Sobol / Morris / FAST
|
|
||||||
- 不实现 NSGA-II / SPEA2 等完整多目标进化算法(用 pymoo)
|
|
||||||
- 不实现符号计算引擎(用 SymPy)
|
|
||||||
- 不实现完整离散事件仿真引擎(`simulate_queue` 那种)
|
|
||||||
- 不做深度学习 / PyTorch 相关任何东西
|
|
||||||
- 不做 GUI、不做 Web 界面、不做 CLI 之外的产品化
|
|
||||||
- 不做自动微分、不做 GPU 后端
|
|
||||||
- 不为"完整性"补函数——**没在真题里用过三次的函数不写**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、开发纪律
|
|
||||||
|
|
||||||
### 3.1 文档两档制
|
|
||||||
|
|
||||||
**S 档**(招牌算法,自实现):完整函数卡片,模板见 `dev/function-card-template.md`。
|
|
||||||
覆盖:`detect_outliers` `bootstrap_ci` `monte_carlo` `monte_carlo_probability`
|
|
||||||
`monte_carlo_integral` `dynamic_programming` `knapsack_dp` `simulated_annealing`
|
|
||||||
`local_sensitivity` `sobol_analyze` `entropy_weights` `topsis` `ahp_weights`
|
|
||||||
`gm11_fit_predict`。约 15 个。
|
|
||||||
|
|
||||||
**A 档**(薄封装):docstring 里一句话数学含义 + 一句话什么时候用 + 一条 pytest。
|
|
||||||
|
|
||||||
判断标准:**我会不会去改它的内部实现?** 会 → S 档。
|
|
||||||
|
|
||||||
### 3.2 每写一个函数
|
|
||||||
|
|
||||||
1. 先想"这道题里它替代了哪段手写代码"——想不出来就先不写。
|
|
||||||
2. 先在 `tests/` 里写一条会失败的断言(测已知解析解,不测实现细节)。
|
|
||||||
- 参数类型注解按 `02-architecture.md` §4.7 写全;非法类型抛 `TypeError`,不静默兜底。
|
|
||||||
3. 实现到测试通过。
|
|
||||||
4. 在 `examples/` 里加一行最小用法。
|
|
||||||
5. 更新 `STATUS.md`。
|
|
||||||
|
|
||||||
### 3.3 提交前
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.venv\Scripts\python.exe -m pytest -q
|
|
||||||
.venv\Scripts\python.exe -m ruff check src tests
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、时间盒
|
|
||||||
|
|
||||||
| 周 | 目标 | 完成标志 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | `core` + `data` + `stats`;S 档先写 `bootstrap_ci` `monte_carlo` `detect_outliers` | L0 全部通过测试 |
|
|
||||||
| 2–3 | `models` + `solve/optimize` + `solve/simulate` | `compare_models` 出对比表 |
|
|
||||||
| 4 | `solve/sensitivity` + `decision` + `viz` | 一条端到端 example |
|
|
||||||
| 5–6 | 用一道真题走完整流程,反向补函数 | `problems/<年份>_<题号>/` 完成 |
|
|
||||||
| 之后 | 每做一题沉淀 1~3 个函数 + 对应 test | 仓库自然长大 |
|
|
||||||
|
|
||||||
**核心原则:先跑通一条完整链路,再补宽度。**
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
# 环境配置
|
|
||||||
|
|
||||||
> 记录本机(Windows / PowerShell)上 conda 与 venv 两套环境的**实际**配置方式、
|
|
||||||
> 实测数据、以及踩过的坑。相关脚本在 `scripts/`。
|
|
||||||
>
|
|
||||||
> 状态:**两套环境均已建好、版本已对齐、验证通过**(2026-10-01)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、结论速查
|
|
||||||
|
|
||||||
| | venv | conda env |
|
|
||||||
|---|---|---|
|
|
||||||
| 路径 | `D:\MCM&ICM_Toolkit\.venv` | `D:\anaconda\envs\mcm-toolkit` |
|
|
||||||
| 解释器 | Anaconda base,Python **3.13.9** | Python **3.13.15** |
|
|
||||||
| 占用 | 10.5 MB | 1.55 GB(+ 共用 pkgs 缓存) |
|
|
||||||
| 来源 | `--system-site-packages` 复用 base | conda 从 defaults 通道安装 |
|
|
||||||
| 用途 | 轻量开发 / 离线兜底 | **跑题主力环境** |
|
|
||||||
| 缺什么 | SALib、pymoo、pyright | 无缺口 |
|
|
||||||
|
|
||||||
**两套的数值计算栈已完全一致**(见 §5),只差 Python 补丁号。可以放心在两套之间切换。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、venv(轻量、离线可用)
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
D:\anaconda\python.exe -m venv --system-site-packages --prompt "mcm-toolkit" .venv
|
|
||||||
.venv\Scripts\python.exe -m pip install -e . --no-deps --no-build-isolation
|
|
||||||
```
|
|
||||||
|
|
||||||
一键:`./scripts/setup-venv.ps1`
|
|
||||||
|
|
||||||
### 为什么用 `--system-site-packages`
|
|
||||||
|
|
||||||
让 venv 直接复用 Anaconda base 里已有的包,**零下载**就有可用的科学栈,
|
|
||||||
而且只有 10.5 MB,可以安心放在项目目录里。
|
|
||||||
|
|
||||||
已验证可导入(版本随 base 走,2026-10-01 base 升级后为):
|
|
||||||
|
|
||||||
`text
|
|
||||||
numpy 2.4.6 scipy 1.18.1 pandas 3.0.6 matplotlib 3.11.2
|
|
||||||
scikit-learn 1.9.1 statsmodels 0.15.0 networkx 3.6.1 sympy 1.14.0
|
|
||||||
`
|
|
||||||
|
|
||||||
### `--no-deps --no-build-isolation` 是必须的
|
|
||||||
|
|
||||||
* `--no-deps`:依赖已由 base 提供,不必联网解析;
|
|
||||||
* `--no-build-isolation`:否则 pip 会新建隔离环境去下载 setuptools 构建后端。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、conda 环境 `mcm-toolkit`(跑题主力)
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 一键(推荐)
|
|
||||||
.\scripts\setup-conda.ps1
|
|
||||||
|
|
||||||
# 等价的手工命令
|
|
||||||
$env:CONDA_PLUGINS_AUTO_ACCEPT_TOS = 'yes' # 见 §4 坑 1
|
|
||||||
conda create -n mcm-toolkit -y python=3.13 ^
|
|
||||||
numpy scipy pandas matplotlib scikit-learn statsmodels ^
|
|
||||||
networkx sympy openpyxl pytest ruff pip
|
|
||||||
```
|
|
||||||
|
|
||||||
**实测:约 14.5 分钟(868 秒),下载约 3 GB,环境本体 1.55 GB。**
|
|
||||||
|
|
||||||
建完后补装 pip-only 的依赖并安装本项目:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$py = 'D:\anaconda\envs\mcm-toolkit\python.exe'
|
|
||||||
& $py -m pip install SALib pymoo pyright
|
|
||||||
& $py -m pip install -e . --no-deps --no-build-isolation
|
|
||||||
```
|
|
||||||
|
|
||||||
这些 `setup-conda.ps1` 已全部自动完成。
|
|
||||||
|
|
||||||
### 为什么不推荐 `--clone base`
|
|
||||||
|
|
||||||
原以为 `conda create -n X --clone base` 是"本地复制、秒级完成、不用下载"。
|
|
||||||
**实测不是。** 在 conda 25.11 上它会重放 base 的完整包列表并逐个下载安装
|
|
||||||
(连 pandoc、m2-msys2-runtime、anaconda-navigator 这些都用不上的也照下),
|
|
||||||
跑 253 秒才下完约 2 GB 就已被中止,按比例算远慢于精准列表。
|
|
||||||
|
|
||||||
所以 `setup-conda.ps1` 的默认路径是**精准包列表**;`-CloneBase` 只在你确实想
|
|
||||||
要一份 base 的完整副本时才用。
|
|
||||||
|
|
||||||
### 断网怎么办
|
|
||||||
|
|
||||||
**提前建好环境**,并保留 `D:\anaconda\pkgs` 缓存(现在 8.4 GB)。
|
|
||||||
包已在缓存里时 `conda create --offline` 才能工作;否则会失败。
|
|
||||||
比赛现场不要指望临时建环境,走 venv(它 100% 离线可用)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、踩过的坑
|
|
||||||
|
|
||||||
**坑 1 —— Anaconda ToS 插件要联网。**
|
|
||||||
Anaconda 25.x 自带 `conda_anaconda_tos` 插件,任何 `create/install` 之前都会去
|
|
||||||
`repo.anaconda.com` 拉 ToS 元数据。断网时它不是报"没网",而是抛一长串 traceback。
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:CONDA_PLUGINS_AUTO_ACCEPT_TOS = 'yes' # 推荐:跳过联网,保留 libmamba 求解器
|
|
||||||
conda --no-plugins create ... # 彻底关插件(退化成经典求解器,慢)
|
|
||||||
```
|
|
||||||
|
|
||||||
**坑 2 —— `envs_dirs` / `pkgs_dirs` 必须可写。**
|
|
||||||
不可写时报 `NotWritableError` 或
|
|
||||||
`Error encountered while attempting to create cache directory`。排查:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
conda info --json | Select-String "envs_dirs","pkgs_dirs"
|
|
||||||
conda config --show envs_dirs pkgs_dirs
|
|
||||||
```
|
|
||||||
|
|
||||||
**坑 3 —— 被中止的 `conda create` 用 `conda env remove` 删不干净。**
|
|
||||||
本次中止后 `conda env remove -n mcm-toolkit -y` 跑完,`D:\anaconda\envs\mcm-toolkit`
|
|
||||||
**仍然存在**且 `conda env list` 里已看不到它。要手工删目录:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Remove-Item -LiteralPath 'D:\anaconda\envs\mcm-toolkit' -Recurse -Force
|
|
||||||
```
|
|
||||||
|
|
||||||
**坑 4 —— 别把 conda env 建在项目目录里。**
|
|
||||||
`conda create -p ./.conda` 会把 GB 级目录放进仓库,脏 `.gitignore` 也容易误提交。
|
|
||||||
|
|
||||||
**坑 5 —— 版本漂移。**
|
|
||||||
本机 base 是 2025-10 装的,defaults 通道已是 2026-10 的版本,于是新建的 conda
|
|
||||||
环境拿到的是更新的版本(pandas 从 2.3.3 跳到 **3.0.6**,是 major 升级)。
|
|
||||||
见 §5,写代码时注意别依赖两套环境都有的行为。
|
|
||||||
|
|
||||||
**坑 6 —— conda 升级留下的 `*.conda_trash` 残骸会被 Python 当成包导入。**
|
|
||||||
base 从 2025.12 升到 2026.07 后,`D:\anaconda\Lib\site-packages\zstandard\` 里只剩一个
|
|
||||||
`backend_c.cp313-win_amd64.pyd.c~.conda_trash`。目录没了 `__init__.py`,Python 就把它当作
|
|
||||||
**namespace package** 导入成功,于是:
|
|
||||||
|
|
||||||
```text
|
|
||||||
>>> import zstandard
|
|
||||||
>>> zstandard.__file__ # None —— 导入"成功"了,但什么都没拿到
|
|
||||||
>>> zstandard.__version__
|
|
||||||
AttributeError
|
|
||||||
```
|
|
||||||
|
|
||||||
pandas 3.0 的 `import_optional_dependency("zstandard")` 拿到这种半残包时,抛的不是
|
|
||||||
`ModuleNotFoundError`(那会被正常忽略),而是
|
|
||||||
`ImportError: Can't determine version for zstandard`,直接**打断 `pd.read_csv`**。
|
|
||||||
症状很迷惑:同一个脚本今天能跑明天不能跑,报错还指向 csv 读取。
|
|
||||||
|
|
||||||
**怎么查**(关键是 `__file__` 和 `__version__` 两项一起看):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
& 'D:\anaconda\python.exe' -c "import zstandard; print(zstandard.__file__, getattr(zstandard,'__version__','MISSING'))"
|
|
||||||
Get-ChildItem -LiteralPath 'D:\anaconda\Lib\site-packages\zstandard' -Force
|
|
||||||
```
|
|
||||||
|
|
||||||
**怎么修**:确认 conda 记录里本来就没有这个包(`conda list -n base | findstr zstandard`
|
|
||||||
只该看到 `backports.zstd` / `zstd`),再把这个残骸目录改名禁用(比删掉可逆):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Move-Item -LiteralPath 'D:\anaconda\Lib\site-packages\zstandard' `
|
|
||||||
-Destination 'D:\anaconda\Lib\site-packages\zstandard.disabled-conda-trash'
|
|
||||||
```
|
|
||||||
|
|
||||||
改名后 `import zstandard` 变成正常的 `ModuleNotFoundError`,pandas 会安静地跳过它。
|
|
||||||
本次已按此处理,`.venv` 的 `pd.read_csv` 恢复正常。
|
|
||||||
|
|
||||||
**坑 7 —— 编辑器报"无法解析导入 numpy / pandas",但解释器明明是对的。**
|
|
||||||
|
|
||||||
现象:`.venv` 与 conda 环境里 `import numpy, pandas` 都正常,pytest 也全过,
|
|
||||||
只有编辑器/pyright 满屏 `无法解析导入"numpy"`。
|
|
||||||
|
|
||||||
原因是**静态分析器自己挑了一个 python**,跟你运行代码用的那个没关系。本机上
|
|
||||||
`pyright --verbose` 一跑就露馅:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Assuming Python version 3.14.6.final.0
|
|
||||||
Search paths:
|
|
||||||
c:\Users\guzhu\AppData\Roaming\Python\Python314\site-packages
|
|
||||||
c:\Users\guzhu\AppData\Local\Python\pythoncore-3.14-64\Lib\site-packages
|
|
||||||
... <- 一个 Anaconda 路径都没有
|
|
||||||
```
|
|
||||||
|
|
||||||
那个 3.14 来自 PATH 上的 `python`,而 PATH 上的 `python` 是本机唯一的 python:
|
|
||||||
|
|
||||||
```text
|
|
||||||
where python -> C:\Users\guzhu\agent-tools\bin\python.cmd
|
|
||||||
\-> C:\Users\guzhu\.cache\codex-runtimes\...\python\python.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
**Codex 运行时的 shim**(Python 3.14,没有 numpy/pandas)。分析器从 PATH 找到它,
|
|
||||||
自然解析不到科学栈。注意持久化的 User/Machine PATH 里**并没有**这一项,
|
|
||||||
它是被 Codex 进程临时注入的——所以"我的终端里没问题"和"工具有问题"可以同时成立。
|
|
||||||
|
|
||||||
还有第二层原因:`.venv` 是 `--system-site-packages` 建的,三方包都在
|
|
||||||
`D:\anaconda\Lib\site-packages`,而 **pyright / Pylance 不认 `pyvenv.cfg` 里的
|
|
||||||
`include-system-site-packages`**,只会去 `.venv\Lib\site-packages` 找——
|
|
||||||
那里只有 `pip`,于是照样报解析不到。
|
|
||||||
|
|
||||||
修法(两件事都要做):
|
|
||||||
|
|
||||||
1. 在 `pyproject.toml` 里把分析器钉到工作区 venv,别让它去 PATH 上碰运气:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[tool.pyright]
|
|
||||||
venvPath = "."
|
|
||||||
venv = ".venv"
|
|
||||||
pythonVersion = "3.13" # 不写就跟着 PATH 上那个 3.14 走
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 在 `.venv\Lib\site-packages` 放一个 `.pth` 指向 base 的 site-packages。
|
|
||||||
pyright **不读** `pyvenv.cfg`,但**会读 `.pth`**:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
& 'D:\anaconda\python.exe' -c "import sysconfig; print(sysconfig.get_paths()['purelib'])"
|
|
||||||
# 把输出的那一行原样写进 .venv\Lib\site-packages\_mcm_base_site_packages.pth
|
|
||||||
```
|
|
||||||
|
|
||||||
`scripts/setup-venv.ps1` 已经自动做这一步(第 [2/4] 步),换机重建 `.venv` 不用手动补。
|
|
||||||
|
|
||||||
改完 pyright 从 `17 errors` 掉到 `0 errors`。IDE 侧还要各做一件事:
|
|
||||||
|
|
||||||
* **VS Code / Cursor**:`Ctrl+Shift+P` → Python: Select Interpreter → 选
|
|
||||||
`D:\anaconda\envs\mcm-toolkit\python.exe`(或 `.venv\Scripts\python.exe`),
|
|
||||||
然后 Developer: Reload Window。Pylance 有缓存,不重载不生效。
|
|
||||||
* **PyCharm**:Settings → Project → Python Interpreter 选同一个,
|
|
||||||
再 File → Invalidate Caches / Restart。
|
|
||||||
|
|
||||||
**一句诊断口诀**:编辑器说"解析不到包"时,先跑 `pyright --verbose` 看它的
|
|
||||||
`Search paths`,而不是先去 `pip install`。
|
|
||||||
|
|
||||||
**坑 8 —— `df[Index]` 被 pyright 当成 `Series`,`.std()` 的结果于是被判成标量。**
|
|
||||||
|
|
||||||
现象:`data/clean.py` 里
|
|
||||||
|
|
||||||
```python
|
|
||||||
numeric = data[numeric_cols] # numeric_cols = df.select_dtypes(...).columns
|
|
||||||
std = numeric.std()
|
|
||||||
std = std.replace(0, np.nan) # pyright: 无法访问类"float"的属性"replace"
|
|
||||||
```
|
|
||||||
|
|
||||||
运行时 `numeric` 明明是 DataFrame、`std` 明明是按列的 Series,pyright 却报这个错。
|
|
||||||
|
|
||||||
原因:pandas 自带的类型注解里,`DataFrame.__getitem__` 只声明了「`Hashable` → `Series`」
|
|
||||||
这一支。拿一个 `Index` 去索引时它挑中这个重载,`numeric` 就被推断成 `Series | DataFrame`;
|
|
||||||
而 `Series.std()` 返回的是**标量**,两支一取并集就把 `float` 带了进来 —— `float` 当然没有
|
|
||||||
`.replace`。用 `reveal_type` 一问就现原形:
|
|
||||||
|
|
||||||
```text
|
|
||||||
“numeric”的类型为“Series | Unknown | Self@DataFrame”
|
|
||||||
“numeric.std(axis=0)”的类型为“Series | float | Unknown”
|
|
||||||
```
|
|
||||||
|
|
||||||
几个**没用**的修法(都实测过):
|
|
||||||
|
|
||||||
- `numeric.std(axis=0)` —— 接收方本身还是并集,重载照样解析成并集;
|
|
||||||
- `data[list(numeric_cols)]` —— 不改变重载选择;
|
|
||||||
- 把参数改由一个类型明确的 `frame` 局部变量承接 —— `frame` 确实干净了,
|
|
||||||
但 `frame[Index]` 还是同一个重载问题。
|
|
||||||
|
|
||||||
有效的修法是**只在取列子表那一句上按运行时的真值收窄**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
numeric = cast(pd.DataFrame, data[numeric_cols])
|
|
||||||
```
|
|
||||||
|
|
||||||
`cast` 零运行时开销,而且能把**下面所有行**留在类型检查范围内 —— 比在 `.replace` 那一行
|
|
||||||
挂 `# pyright: ignore` 好:那样等于连带放弃后面每一行的检查。
|
|
||||||
|
|
||||||
**一句诊断口诀**:pyright 说某行"属性不存在"、但运行时确实能跑,先 `reveal_type(...)`
|
|
||||||
把真实推断打出来,看并集里混进了什么,别急着加 `# type: ignore`。
|
|
||||||
|
|
||||||
**坑 9 —— pandas 3 起字符串列的 dtype 是 `str`,`select_dtypes(include=["object"])` 已走弃用路径。**
|
|
||||||
|
|
||||||
现象:`one_hot_encode` 自动识别类别列时只写了 `['object', 'category']`,
|
|
||||||
功能看起来正常,但**每次调用都发一条 `Pandas4Warning`**,而且 warning 里写着这个行为
|
|
||||||
"will be removed in a future version"。
|
|
||||||
|
|
||||||
```text
|
|
||||||
For backward compatibility, 'str' dtypes are included by select_dtypes when 'object'
|
|
||||||
dtype is specified. This behavior is deprecated and will be removed in a future version.
|
|
||||||
```
|
|
||||||
|
|
||||||
原因:pandas 3 起字符串列的 dtype 是 `str`(不再是 `object`)。当前版本为了向后兼容
|
|
||||||
仍然把 `str` 列算进 `object`,但那是**过渡行为**。等它真的移除,`include=['object']`
|
|
||||||
会一列都选不中 —— 那时 `one_hot_encode(df)` 会**静默**返回未编码的原表,
|
|
||||||
不报错、不告警,是最难查的那类错。
|
|
||||||
|
|
||||||
实测(本机 pandas 3.0.6):
|
|
||||||
|
|
||||||
| 写法 | 选中 | 告警 |
|
|
||||||
|---|---|---|
|
|
||||||
| `include=['object', 'category']` | ✅ | **1 条** |
|
|
||||||
| `include=['object', 'category', 'str', 'string']` | ✅ | 0 |
|
|
||||||
| `include=['str', 'string']` | ✅ | 0 |
|
|
||||||
|
|
||||||
修法:把 `'str'` / `'string'` 显式写进 `include`(这也是 pandas 官方迁移指南推荐的写法,
|
|
||||||
同时兼容 pandas 2 与 3)。配套测试用 `warnings.catch_warnings(record=True)` 断言
|
|
||||||
**一条告警都不该有**,这样以后 pandas 再收紧行为时会立刻变红,不会静默。
|
|
||||||
|
|
||||||
**一句诊断口诀**:`select_dtypes` 选不中东西时,先 `print(df[col].dtype)` 看真实 dtype,
|
|
||||||
别照着记忆里的 `object` 写。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**坑 10 —— scipy 的检验结果在类型检查器里没有 `pvalue` / `statistic` 字段。**
|
|
||||||
|
|
||||||
现象:`_, p = stats.pearsonr(x, y)` 运行没问题,但 pyright 报
|
|
||||||
`无法将 "_T_co@tuple" 类型的参数分配给 ... 的 "float"`;改成 `.pvalue` 又报
|
|
||||||
`无法访问类 "PearsonRResult" 的属性 "pvalue"`。两条都像"库的锅",其实是存根看不全。
|
|
||||||
|
|
||||||
原因:`PearsonRResult` / `SignificanceResult` 不是普通的 `namedtuple`,
|
|
||||||
而是在 `scipy/stats/_stats_py.py` 里用 `_make_tuple_bunch(...)` 在**运行时**拼出来的类
|
|
||||||
(`PearsonRResult` 还继承自 `PearsonRResultBase`)。类型检查器看不到运行时才生成的字段,
|
|
||||||
于是解包会退化成 `object`、属性访问会报"未知"。
|
|
||||||
**这不是运行时问题** —— `pearsonr` / `kendalltau` / `spearmanr` 返回的 p 实测都是 `np.float64`。
|
|
||||||
|
|
||||||
修法:不要逐处挂 `# type: ignore`,写一个小助手把结果显式收成 `Any`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _pvalue(result: Any) -> float:
|
|
||||||
return float(result.pvalue)
|
|
||||||
|
|
||||||
def _statistic(result: Any) -> float:
|
|
||||||
return float(result.statistic)
|
|
||||||
```
|
|
||||||
|
|
||||||
一处收窄、调用点全干净,`t_test` / `anova` / `chi_square_test` / `fit_distribution` 都能复用。
|
|
||||||
|
|
||||||
**`TtestResult` 同理。** `stats.ttest_1samp` / `ttest_ind` / `ttest_rel` 返回的也是
|
|
||||||
`_make_tuple_bunch` 在运行时拼出来的类,存根里 `.statistic` 和 `.pvalue` **两个字段都没有**。
|
|
||||||
`inference.py` 的 `t_test` 里 `float(res.statistic)` / `float(res.pvalue)` 两行就是这么被 pyright 标红的
|
|
||||||
(实测:`TtestResult(statistic=3.873, pvalue=0.0305)`,运行时取值完全正常)。
|
|
||||||
换成 `_statistic(res)` / `_pvalue(res)` 后红线消失,**一行 `# type: ignore` 都没挂**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、两套环境的实际版本(已对齐)
|
|
||||||
|
|
||||||
**2026-10-01 已把 base 从 Anaconda 2025.12 升到 2026.07,两套环境版本已对齐。**
|
|
||||||
|
|
||||||
| 包 | venv(继承 base) | conda `mcm-toolkit` | |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Python | 3.13.9 | 3.13.15 | 补丁号,可忽略 |
|
|
||||||
| numpy | 2.4.6 | 2.4.6 | ✅ 一致 |
|
|
||||||
| scipy | 1.18.1 | 1.18.1 | ✅ 一致(连 build 号都相同) |
|
|
||||||
| pandas | 3.0.6 | 3.0.6 | ✅ 一致 |
|
|
||||||
| matplotlib | 3.11.2 | 3.11.2 | ✅ 一致 |
|
|
||||||
| scikit-learn | 1.9.1 | 1.9.1 | ✅ 一致 |
|
|
||||||
| statsmodels | 0.15.0 | 0.15.0 | ✅ 一致 |
|
|
||||||
| networkx | 3.6.1 | 3.6.1 | ✅ 一致 |
|
|
||||||
| sympy | 1.14.0 | 1.14.0 | ✅ 一致 |
|
|
||||||
| pytest | 9.1.1 | 9.1.1 | ✅ 一致 |
|
|
||||||
| SALib / pymoo / pyright | 缺失 | 已装 | venv 本来就只做轻量开发 |
|
|
||||||
| BLAS 后端 | **MKL** | **MKL** | ✅ 一致 |
|
|
||||||
|
|
||||||
### base 升级记录
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
conda update -n base --all -y # 365 个包更新,下载 638 MB,耗时 1033 秒
|
|
||||||
conda install -n mcm-toolkit numpy=2.4.6 -y # 把项目环境对齐到 base 的 numpy
|
|
||||||
```
|
|
||||||
|
|
||||||
* `conda` 本身从 25.11.1 升到 **26.7.3**。
|
|
||||||
* 升级前 `conda list -n base --revisions` 里已留有 revision 记录,可回滚:
|
|
||||||
`conda install -n base --revision <N>`。
|
|
||||||
* **踩过的坑**:base 想装 `numpy=2.5.3` 时求解器会顺手把 BLAS 从 **MKL 换成 OpenBLAS**
|
|
||||||
(`_anaconda_depends 2026.07 ..._mkl_1 → ..._openblas_1`、`blas 1.0-mkl → 1.0-openblas`)。
|
|
||||||
`_anaconda_depends` 把 base 的 numpy 钉在 2.4.6,硬升会牵动整套线性代数后端,
|
|
||||||
所以选择把项目环境的 numpy **降到 2.4.6** 来对齐,而不是动 base。
|
|
||||||
* 升级期间遇到 `repo.anaconda.com` 读超时(默认 9.15 秒太短),已把
|
|
||||||
`remote_read_timeout_secs` 调到 120、`remote_connect_timeout_secs` 调到 30、
|
|
||||||
`remote_max_retries` 调到 5。这几项写在 `~/.condarc` 里。
|
|
||||||
|
|
||||||
## 六、国内镜像(需要装新包时)
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main
|
|
||||||
conda config --set show_channel_urls yes
|
|
||||||
|
|
||||||
# pip(对 venv 和 conda 环境都生效)
|
|
||||||
python -m pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
|
||||||
```
|
|
||||||
|
|
||||||
> 本机当前通过 `http://127.0.0.1:7993` 的 UniClash 代理直连,未配镜像。镜像只影响**下载**,
|
|
||||||
> 不影响坑 1 的 ToS 行为。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、可选依赖
|
|
||||||
|
|
||||||
核心 4 个包(`numpy scipy pandas matplotlib`)两套环境都有。其余按需:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python -m pip install -e ".[ml]" # sklearn statsmodels 已有
|
|
||||||
python -m pip install -e ".[graph]" # networkx 已有
|
|
||||||
python -m pip install -e ".[sensitivity]" # SALib conda 环境已有
|
|
||||||
python -m pip install -e ".[symbolic]" # sympy 已有
|
|
||||||
python -m pip install -e ".[moo]" # pymoo conda 环境已有
|
|
||||||
python -m pip install -e ".[excel]" # openpyxl 已有
|
|
||||||
python -m pip install -e ".[dev]" # pytest ruff pyright
|
|
||||||
```
|
|
||||||
|
|
||||||
缺依赖时对应函数应当给出可读的安装提示,而不是裸抛 `ImportError`
|
|
||||||
(写成约定,见 `docs/02-architecture.md` §4.6)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、脚本的编码约定(踩过的坑)
|
|
||||||
|
|
||||||
`scripts/` 下的 `.ps1` **必须是纯 ASCII + UTF-8 无 BOM + LF**。
|
|
||||||
|
|
||||||
原因是 Windows PowerShell 5.1 **在没有 BOM 时按 ANSI(本机 GBK)解析 `.ps1`**,
|
|
||||||
中文注释和中文消息会被拆成非法字节,报出来的却是一堆莫名其妙的
|
|
||||||
`Missing closing '}' in statement block` / `The string is missing the terminator`。
|
|
||||||
(实测:带中文的 `doctor.ps1` 直接语法错误;改成纯 ASCII 后立刻正常。)
|
|
||||||
|
|
||||||
两种可行方案,本项目选了第二种:
|
|
||||||
|
|
||||||
1. 保留中文 → 必须存成 **UTF-8 with BOM**(违反 AGENTS.md 的"无 BOM"约定);
|
|
||||||
2. **纯 ASCII,注释与输出用英文** → 符合 AGENTS.md,且任何编码下都能解析。← 采用
|
|
||||||
|
|
||||||
另一个相关的坑:**PowerShell 5.1 向原生程序传参时会吃掉双引号**,
|
|
||||||
所以 `doctor.ps1` 里传给 `python -c` 的探测代码只能用单引号字符串。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、已知的沙箱相关现象
|
|
||||||
|
|
||||||
如果在受限环境(文件系统只读 / 无网络)里跑 matplotlib,会看到:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Matplotlib created a temporary cache directory at ... because there was an issue with
|
|
||||||
the default path (C:\Users\guzhu\.matplotlib)
|
|
||||||
```
|
|
||||||
|
|
||||||
这不是配置问题,是 `~\.matplotlib` 不可写。正常终端里不会出现;
|
|
||||||
真遇到时设置 `MPLCONFIGDIR` 指向可写目录即可:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:MPLCONFIGDIR = "$env:TEMP\matplotlib"
|
|
||||||
```
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
# Git 工作流与远程托管
|
|
||||||
|
|
||||||
> 本仓库**已初始化**:分支 `main`,首次提交 `5319589`(76 文件),工作区干净。
|
|
||||||
> 尚未配置远程,之后托管到你自己的 Gitea。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、仓库约定
|
|
||||||
|
|
||||||
### 行尾
|
|
||||||
|
|
||||||
由 `.gitattributes` 强制:**仓库内一律 LF**,checkout 时不做自动转换。
|
|
||||||
|
|
||||||
原因:AGENTS.md 要求 shell 脚本 UTF-8 无 BOM + LF;而这个仓库会被队友在
|
|
||||||
Windows / WSL / Linux 上 clone。不固定行尾的话,每次提交都会出现
|
|
||||||
"整个文件都变了"的假 diff,review 时根本看不出真实改动。
|
|
||||||
|
|
||||||
例外:`*.bat` / `*.cmd` 保持 CRLF(cmd 解析需要)。
|
|
||||||
|
|
||||||
Windows 用户建议同时设:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git config --global core.autocrlf false
|
|
||||||
git config --global core.eol lf
|
|
||||||
```
|
|
||||||
|
|
||||||
### 什么不入库
|
|
||||||
|
|
||||||
见 `.gitignore`。重点是三条:
|
|
||||||
|
|
||||||
| 忽略 | 为什么 |
|
|
||||||
|---|---|
|
|
||||||
| `.venv/`、`.conda/` | 环境可由 `scripts/setup-*.ps1` 重建,不该进版本库 |
|
|
||||||
| `problems/**/raw/`、`**/*.xlsx`、`problems/**/*.csv` | 赛题原始数据体积大、可重新下载;只提交**处理脚本和结论** |
|
|
||||||
| `figures/*.png`、`figures/*.pdf`、`tables/*.csv` | 中间产物。定稿的图请放进 `problems/<年份>_<题号>/figures/` 再提交 |
|
|
||||||
|
|
||||||
**判断标准:这个东西能不能由仓库里的其他文件重新生成?**
|
|
||||||
能 → 不入库。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、日常提交
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git add -A
|
|
||||||
git commit -m "feat(stats): 实现 bootstrap_ci"
|
|
||||||
```
|
|
||||||
|
|
||||||
提交信息建议带模块前缀,方便以后回溯"这个函数是哪次加的":
|
|
||||||
|
|
||||||
| 前缀 | 用途 |
|
|
||||||
|---|---|
|
|
||||||
| `feat(<模块>)` | 新增函数 / 功能 |
|
|
||||||
| `fix(<模块>)` | 修 bug |
|
|
||||||
| `test(<模块>)` | 只改测试 |
|
|
||||||
| `docs` | 只改文档 |
|
|
||||||
| `env` | 环境、依赖、脚本 |
|
|
||||||
| `chore` | 其他杂项 |
|
|
||||||
|
|
||||||
提交前跑一遍:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\scripts\doctor.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
它会同时检查 venv 和 conda 两套环境并跑测试。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、远程托管(已配置)
|
|
||||||
|
|
||||||
| 项 | 值 |
|
|
||||||
|---|---|
|
|
||||||
| Gitea 网页 | https://code.guzhujushi.cn/guzhujushi/MCM-ICM_Toolkit |
|
|
||||||
| 服务器公网 IP | `8.145.36.108`(实测只开了 443,**22 被防火墙过滤**) |
|
|
||||||
| 服务器 Tailscale IP | `100.115.86.48`(22 开放) |
|
|
||||||
| 本机 Tailscale IP | `100.124.243.30` |
|
|
||||||
| git 远端 | `git@code.guzhujushi.cn:guzhujushi/MCM-ICM_Toolkit.git` |
|
|
||||||
|
|
||||||
### 为什么走 Tailscale
|
|
||||||
|
|
||||||
公网 22 端口连不上(`ssh: connect to host code.guzhujushi.cn port 22: Connection timed out`),
|
|
||||||
但同一台机器的 Tailscale 地址 22 端口是通的,且 `git@` 认证直接通过 Gitea。
|
|
||||||
所以 `~/.ssh/config` 里加了一条别名把域名指到内网地址:
|
|
||||||
|
|
||||||
```sshconfig
|
|
||||||
Host code.guzhujushi.cn
|
|
||||||
HostName 100.115.86.48
|
|
||||||
User git
|
|
||||||
IdentityFile ~/.ssh/id_ed25519
|
|
||||||
```
|
|
||||||
|
|
||||||
这样 remote 里写的是域名,换机时只改这一段 config,不用动仓库配置。
|
|
||||||
|
|
||||||
**推送前先连上 Tailscale**,否则 `git push` 会一直等到超时。
|
|
||||||
|
|
||||||
### 自检
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
ssh -T git@code.guzhujushi.cn
|
|
||||||
# 期望输出:Hi there, guzhujushi! You've successfully authenticated with the key named ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 日常推送
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd D:\MCM&ICM_Toolkit
|
|
||||||
git add -A
|
|
||||||
git commit -m "feat(stats): 实现 bootstrap_ci"
|
|
||||||
git push
|
|
||||||
```
|
|
||||||
|
|
||||||
### 换一台机器 / 加一个协作者
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 1) 装 Tailscale 并加入同一个 tailnet
|
|
||||||
# 2) 生成并上传密钥
|
|
||||||
ssh-keygen -t ed25519 -C "your@email"
|
|
||||||
Get-Content "$env:USERPROFILE\.ssh\id_ed25519.pub" | Set-Clipboard
|
|
||||||
# 粘贴到 Gitea → 设置 → SSH/GPG 密钥
|
|
||||||
# 3) 写上面的 ~/.ssh/config 别名
|
|
||||||
# 4) 克隆
|
|
||||||
git clone git@code.guzhujushi.cn:guzhujushi/MCM-ICM_Toolkit.git
|
|
||||||
```
|
|
||||||
|
|
||||||
不想用 Tailscale 的话,也可以用 HTTPS + Gitea 访问令牌,
|
|
||||||
代价是每次推送要输凭据(或交给凭据助手缓存):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git remote add origin https://code.guzhujushi.cn/guzhujushi/MCM-ICM_Toolkit.git
|
|
||||||
```
|
|
||||||
|
|
||||||
> 注意:`git config --global http.proxy` 目前指向 UniClash(`127.0.0.1:7993`),
|
|
||||||
> HTTPS 方式会被代理绕一圈;SSH 方式不受该配置影响。
|
|
||||||
|
|
||||||
### 仓库归属注意(重要)
|
|
||||||
|
|
||||||
`.git` 必须是**你自己的账户**创建的。若用沙箱化的 AI agent 通道代跑 `git init`,
|
|
||||||
它可能以别的账户身份创建 `.git`,之后你自己跑 git 就会报:
|
|
||||||
|
|
||||||
fatal: detected dubious ownership in repository
|
|
||||||
|
|
||||||
正确做法是删掉重建(以本人账户):
|
|
||||||
|
|
||||||
Remove-Item -LiteralPath .git -Recurse -Force
|
|
||||||
git init -b main
|
|
||||||
|
|
||||||
**不要**用 `git config --global --add safe.directory` 绕过——那只是把问题藏起来,
|
|
||||||
而且会让 git 在真正的归属异常时也不再警告。
|
|
||||||
|
|
||||||
同理,沙箱通道写出的文件 Owner 会是沙箱账户,但权限继承自
|
|
||||||
`Authenticated Users`(Modify),读写不受影响,只有 git 的归属检查会误报。
|
|
||||||
|
|
||||||
### 可见性与协作者
|
|
||||||
|
|
||||||
匿名 `git ls-remote` 实测可读 → 仓库当前是 **Public**。要改私有:
|
|
||||||
仓库设置 → 基本设置 → 可见性。加队友:仓库设置 → 协作者 → 添加成员。
|
|
||||||
|
|
||||||
## 四、给队友的说明
|
|
||||||
|
|
||||||
队友 clone 后只需要:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git clone <你的仓库地址>
|
|
||||||
cd mcm-icm-toolkit
|
|
||||||
.\scripts\setup-venv.ps1 # 零下载,复用他们自己的 Anaconda base
|
|
||||||
.\scripts\doctor.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
`setup-venv.ps1` 用的是 `--system-site-packages`,所以他们本机得先有 Anaconda。
|
|
||||||
如果队友没有 Anaconda,让他走 `scripts/setup-conda.ps1`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、回滚
|
|
||||||
|
|
||||||
环境升级出问题时的回退手段:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 远端被覆盖过一次(首次推送),那个提交仍在本机对象库里:
|
|
||||||
# git push origin 1c683cf:refs/heads/restore-initial
|
|
||||||
|
|
||||||
# conda 自己保留了 revision 历史
|
|
||||||
conda list -n base --revisions
|
|
||||||
conda install -n base --revision <N>
|
|
||||||
|
|
||||||
# 代码回滚
|
|
||||||
git log --oneline
|
|
||||||
git revert <commit>
|
|
||||||
```
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 文档索引
|
|
||||||
|
|
||||||
本目录是 MCM/ICM Toolkit 的需求、架构、方案与开发规范的**唯一落盘处**。
|
|
||||||
|
|
||||||
| 文档 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| [01-suggestion-review.md](01-suggestion-review.md) | 对 ChatGPT 建库建议的**审查与精简**:留什么、砍什么、补什么 |
|
|
||||||
| [02-architecture.md](02-architecture.md) | 精简后的包结构、边界划分、统一接口约定 |
|
|
||||||
| [03-roadmap.md](03-roadmap.md) | 功能清单分级(L0 / L1 / L2 / 不做)与开发纪律 |
|
|
||||||
| [04-environment.md](04-environment.md) | conda / venv 环境配置、镜像、离线约束 |
|
|
||||||
| [dev/function-card-template.md](dev/function-card-template.md) | S 档算法的「函数卡片」模板 |
|
|
||||||
| [05-git-workflow.md](05-git-workflow.md) | Git 工作流、Gitea 远程(SSH 走 Tailscale)、行尾策略 |
|
|
||||||
| [dev/personal-pitfalls.md](dev/personal-pitfalls.md) | **个人易错点清单:只追加**,追加前不通读 |
|
|
||||||
|
|
||||||
## 阅读顺序
|
|
||||||
|
|
||||||
1. 先读 `01-suggestion-review.md` —— 它决定后面所有事。
|
|
||||||
2. 再读 `02-architecture.md` —— 它决定代码往哪写。
|
|
||||||
3. 开工前读 `03-roadmap.md` 的对应条目 —— 它决定这次做什么、做到什么程度。
|
|
||||||
4. 环境出问题看 `04-environment.md`。
|
|
||||||
|
|
||||||
> 项目级进度看根目录的 `STATUS.md`;本目录只放**不会频繁变**的内容。
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# 算法卡片
|
|
||||||
|
|
||||||
S 档(自己实现的招牌算法)的**函数卡片**放这里,模板见
|
|
||||||
[`../dev/function-card-template.md`](../dev/function-card-template.md)。
|
|
||||||
|
|
||||||
计划要写的卡片(见 `../03-roadmap.md` §3.1,约 15 张):
|
|
||||||
|
|
||||||
| 卡片 | 对应函数 | 状态 |
|
|
||||||
|---|---|---|
|
|
||||||
| `detect_outliers.md` | `data.detect_outliers` | ✅ 2026-10-02 |
|
|
||||||
| `bootstrap_ci.md` | `stats.bootstrap_ci` | ✅ 2026-10-03(§10 有 5 个口径待拍板)|
|
|
||||||
| `monte_carlo.md` | `solve.monte_carlo` | ⬜ |
|
|
||||||
| `monte_carlo_probability.md` | `solve.monte_carlo_probability` | ⬜ |
|
|
||||||
| `monte_carlo_integral.md` | `solve.monte_carlo_integral` | ⬜ |
|
|
||||||
| `dynamic_programming.md` | `solve.dynamic_programming` | ⬜ |
|
|
||||||
| `knapsack_dp.md` | `solve.knapsack_dp` | ⬜ |
|
|
||||||
| `simulated_annealing.md` | `solve.simulated_annealing` | ⬜ |
|
|
||||||
| `local_sensitivity.md` | `solve.local_sensitivity` | ⬜ |
|
|
||||||
| `sobol_analyze.md` | `solve.sobol_analyze` | ⬜ |
|
|
||||||
| `entropy_weights.md` | `decision.entropy_weights` | ⬜ |
|
|
||||||
| `topsis.md` | `decision.topsis` | ⬜ |
|
|
||||||
| `ahp_weights.md` | `decision.ahp_weights` | ⬜ |
|
|
||||||
| `gm11_fit_predict.md` | `decision.gm11_fit_predict` | ⬜ |
|
|
||||||
|
|
||||||
> 卡片是写给自己半年后看的,不是给评审看的。
|
|
||||||
> 如果一张卡片最后只复述了 docstring,说明这个函数其实是 A 档,应该降档删卡片。
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
# bootstrap_ci
|
|
||||||
|
|
||||||
| 项 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| 模块 | `mcm_toolkit.stats.bootstrap` |
|
|
||||||
| 档位 | S |
|
|
||||||
| 参考来源 | Efron (1979);Efron & Tibshirani (1993) *An Introduction to the Bootstrap*;
|
|
||||||
| | `scipy.stats.bootstrap`(只做对照,没有调用) |
|
|
||||||
| 状态 | 实现已落地(2026-10-03),§10 有 4 个口径待拍板 |
|
|
||||||
|
|
||||||
## 1. 数学定义
|
|
||||||
|
|
||||||
给定样本 `x_1..x_n` 与统计量 `T(·)`:从 `x` 中**有放回**抽 `n` 个得到 `x*`,重复 `B` 次,
|
|
||||||
得 `T*_1..T*_B`。百分位法区间
|
|
||||||
|
|
||||||
```text
|
|
||||||
estimate = T(x) # 用**原样本**,不是重采样分布的均值
|
|
||||||
alpha = 1 - confidence
|
|
||||||
low = T*_(alpha/2) # 经验分布的 alpha/2 分位
|
|
||||||
high = T*_(1 - alpha/2)
|
|
||||||
```
|
|
||||||
|
|
||||||
输入是一个一维样本,输出是「点估计 + 区间」两个标量 —— 这是**非参数**区间,
|
|
||||||
不假设正态、也不需要 `T` 的标准误解析式。
|
|
||||||
|
|
||||||
## 2. 适用场景
|
|
||||||
|
|
||||||
**用**:`T` 没有解析分布(中位数、极差、自定义指标);样本有偏或分布未知;
|
|
||||||
想给区间但不想假设正态(`mean ± z·s/√n` 的替代品)。
|
|
||||||
|
|
||||||
**不用**:`n < 10` —— 经验分布太粗,`x` 能重采样出的不同样本极少;
|
|
||||||
数据有**强时间相关性**(股票、气温),此时必须用 block bootstrap;
|
|
||||||
要估的是**极值**(最大值本身就是上界,见 §5)。
|
|
||||||
|
|
||||||
## 3. 参数与返回值
|
|
||||||
|
|
||||||
| 参数 | 类型 | 默认 | 含义 | 取值影响 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `x` | Any | — | 样本;走 `core.as_array(x, numeric_only=True, ndim=1)` | DataFrame 只取数值列,再摊平 |
|
|
||||||
| `statistic` | Callable | `np.mean` | 任意 `f(sample) -> float` | 换 `np.median` 就得到中位数的区间 |
|
|
||||||
| `confidence` | float | 0.95 | 置信水平 | 越大区间越宽 |
|
|
||||||
| `n_resamples` | int | 10000 | 重采样次数 `B` | 越大端点越稳;10000 通常够 |
|
|
||||||
| `method` | str | `"percentile"` | 区间算法 | `"bca"` 尚未实现,传了抛 `NotImplementedError` |
|
|
||||||
| `random_state` | int \| None | `None` | 随机种子 | `None` 时复用 `core.get_rng` 的模块级生成器且会推进 |
|
|
||||||
|
|
||||||
返回 `Result`:
|
|
||||||
|
|
||||||
| 字段 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| `params` | `estimate` / `low` / `high` / `confidence` / `n_resamples` |
|
|
||||||
| `metrics` | `statistic_name`(`np.mean` → `"mean"`;lambda → `"custom"`)|
|
|
||||||
| `data` | 不使用 |
|
|
||||||
|
|
||||||
## 4. 复杂度
|
|
||||||
|
|
||||||
- 时间:`O(B · n)`(`B` 次重采样,每次都要在 `n` 个值上算一次 `T`)
|
|
||||||
- 空间:`O(B)` —— **按块处理**,每批 `batch_size = 1000` 行,绝不落地 `B × n` 的整矩阵
|
|
||||||
- **什么时候会跑不完**:
|
|
||||||
- `n = 1e4, B = 1e4` → `1e8` 次操作,秒级,正常;
|
|
||||||
- `n = 1e6, B = 1e4` → `1e10` 次操作,**跑不完**;而且 1000×1e6 的单批索引矩阵就要 8 GB,
|
|
||||||
此时应改用解析近似,或把 `B` 降到 1e3 以下、换更便宜的 `T`。
|
|
||||||
|
|
||||||
## 5. 数值稳定性
|
|
||||||
|
|
||||||
- **统计量有上界时,百分位区间会「塌」到上界。** 极差 `max - min` 的重采样值**永远不可能超过**
|
|
||||||
原样本极差,`B` 个值里有相当比例正好等于它(抽到原样本极值点的那些),
|
|
||||||
于是 `high == estimate` 精确成立。这不是 bug,是 bootstrap 的固有性质 ——
|
|
||||||
区间端点是**样本**的经验分位数,不能给出样本之外的结论。要估极值请用极值理论。
|
|
||||||
- 分块只是省内存,不改结果:每批都从同一个 `rng` 按顺序抽索引,等价于一次抽 `B × n`。
|
|
||||||
- 随机数**只来自 `core.get_rng`**(决策 24):给了种子可复现,不给则跟随 `set_seed`。
|
|
||||||
- **NaN 目前不处理**:`x` 里的 NaN 会直接进 `T`,`np.mean` 于是给出 `nan` 区间。见 §10。
|
|
||||||
|
|
||||||
## 6. 常见错误
|
|
||||||
|
|
||||||
1. 不写 seed → 每次跑出来的区间都不一样,论文里的数字对不上 → 传 `random_state` 或先 `set_seed`。
|
|
||||||
2. 以为 bootstrap 能得出**比样本更极端**的结论 → 区间端点就是样本的经验分位数,
|
|
||||||
"95% 置信上限 173.2cm" 不可能超过样本最大值。
|
|
||||||
3. 拿它估最大值 / 最小值 → 区间会塌成一个点(原因同 §5),该换极值理论。
|
|
||||||
4. `n < 10` 硬跑 → 能抽出的不同重采样样本太少,区间没有统计意义。
|
|
||||||
5. 把 `n_resamples` 当成"样本量"写进论文 → 论文里要同时写清 `n`、`B`、seed、method。
|
|
||||||
|
|
||||||
## 7. 最小示例
|
|
||||||
|
|
||||||
```python
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from mcm_toolkit.stats import bootstrap_ci
|
|
||||||
|
|
||||||
r = bootstrap_ci(heights, statistic=np.median, n_resamples=10000, random_state=42)
|
|
||||||
print(r.params["low"], r.params["estimate"], r.params["high"])
|
|
||||||
print(r.summary()) # 可直接贴进论文的 Markdown 表
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 单元测试要点
|
|
||||||
|
|
||||||
| 测什么 | 期望 | 对应测试 |
|
|
||||||
|---|---|---|
|
|
||||||
| 大样本正态,`statistic=np.mean` | 区间半宽 ≈ `2 × 1.96 × se`,容差 10% | `test_bootstrap_ci_matches_normal_theory_on_large_n` |
|
|
||||||
| 任意统计量(中位数,对数正态样本) | 区间包含样本中位数 | `test_bootstrap_ci_works_for_arbitrary_statistic` |
|
|
||||||
| 同种子两次 | `low` 完全相等 | `test_bootstrap_ci_is_reproducible` |
|
|
||||||
| 不给种子 | 跟随 `set_seed`(决策 24) | `test_bootstrap_ci_follows_get_rng_when_no_seed_is_given` |
|
|
||||||
| `estimate` | 等于原样本上的 `T(x)` | `test_bootstrap_ci_reports_the_original_estimate` |
|
|
||||||
| `confidence` 0.80 → 0.95 | 区间单调变宽 | `test_bootstrap_ci_interval_widens_with_confidence` |
|
|
||||||
| 极差作为统计量 | `high <= estimate`(原子点塌陷) | `test_bootstrap_ci_works_for_a_custom_lambda` |
|
|
||||||
| 五种容器 | DataFrame/Series/ndarray/list/tuple 结果一致 | `test_bootstrap_ci_accepts_the_documented_containers` |
|
|
||||||
| `method` 非法 / `"bca"` | `ValueError` / `NotImplementedError` | `test_bootstrap_ci_rejects_unknown_method` |
|
|
||||||
|
|
||||||
## 9. MCM/ICM 用法
|
|
||||||
|
|
||||||
- **哪类题**:任何要"给结论 + 给不确定性"的题 —— 评价类(排名稳定性)、预测类(区间预测)。
|
|
||||||
- **论文里怎么表述**:"采用非参数 bootstrap(`B = 10000`,`seed = 42`)估计中位数的 95% 置信区间。"
|
|
||||||
- **图**:预测曲线 + 阴影置信带(`viz.plot` / `viz.save_figure`)。
|
|
||||||
- **踩坑提醒**:必须报 `B` 和 seed;说清用的是 percentile 还是 BCa(目前只有 percentile);
|
|
||||||
被问到"为什么不用 ±1.96σ"时就答"统计量是中位数,没有解析标准误"。
|
|
||||||
|
|
||||||
## 10. 待拍板(未定稿,Codex 不代选)
|
|
||||||
|
|
||||||
| # | 口径 | 现状 | 可选 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | `x` 里的 NaN | 静默传进 `T` → `nan` 区间 | 像 `confidence_interval` 一样丢掉 / 直接报错 / 维持现状 |
|
|
||||||
| 2 | `n_resamples <= 0` | 走到 `np.percentile` 抛 `IndexError: index -1 is out of bounds` | 入口 `ValueError(必须 ≥ 1)` |
|
|
||||||
| 3 | `confidence` 越界 | 由 numpy 抛 `ValueError: Percentiles must be in the range [0, 100]` | 入口 `ValueError + 可读文案` |
|
|
||||||
| 4 | `method="bca"` | docstring 承诺过,实测 `NotImplementedError` | 补 BCa 实现(S 档,约 25 行)/ 从 docstring 删掉 |
|
|
||||||
| 5 | `bootstrap_statistic` | 仍是 `NotImplementedError`,而 `bootstrap_ci` 把重采样循环内联了 | 抽出去让 `bootstrap_ci` 调它(易错点 16 的变体) |
|
|
||||||
|
|
||||||
> 口径 5 尤其值得做:`bootstrap_statistic` 的 docstring 与 `bootstrap_ci` 里那段循环说的是同一件事,
|
|
||||||
> 现在等于把同一段算法写了两遍,只实现了一遍。
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# detect_outliers
|
|
||||||
|
|
||||||
| 项 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| 模块 | `mcm_toolkit.data.clean` |
|
|
||||||
| 档位 | S |
|
|
||||||
| 参考来源 | Tukey (1977) *Exploratory Data Analysis*(1.5·IQR 围栏的出处);3σ 准则见 NIST/SEMATECH e-Handbook §1.3.5.17;实现直接调用 `pandas.DataFrame.quantile` / `mean` / `std` |
|
|
||||||
| 实现状态 | 已实现(2026-10-02),`tests/test_data.py` 中 9 条用例覆盖 |
|
|
||||||
|
|
||||||
## 1. 数学定义
|
|
||||||
|
|
||||||
设某列数值样本 `x_1..x_n`,记 `Q_1 = quantile(0.25)`、`Q_3 = quantile(0.75)`,
|
|
||||||
四分位距 `IQR = Q_3 - Q_1`。对每个 `x_i` 给出布尔判定:
|
|
||||||
|
|
||||||
**IQR 法**(默认):
|
|
||||||
|
|
||||||
```text
|
|
||||||
m_i = (x_i < Q_1 - k·IQR) or (x_i > Q_3 + k·IQR) 默认 k = 1.5
|
|
||||||
```
|
|
||||||
|
|
||||||
**z-score 法**:
|
|
||||||
|
|
||||||
```text
|
|
||||||
z_i = (x_i - x̄) / s s 为样本标准差(ddof = 1)
|
|
||||||
m_i = |z_i| > t 默认 t = 3.0
|
|
||||||
```
|
|
||||||
|
|
||||||
输入:一张表(`DataFrame`)或一维序列;输出:**与原数据同形的布尔掩码**,
|
|
||||||
`True` 表示该位置是离群点。数学上它是逐元素定义的二值函数,不依赖行与行之间的关系。
|
|
||||||
|
|
||||||
## 2. 适用场景
|
|
||||||
|
|
||||||
**用**:
|
|
||||||
|
|
||||||
- 数据录入错误(身高 260 cm、年龄 −10)这类"明显不可能"的点,先标出来给人看。
|
|
||||||
- 建模前的敏感性分析:删与不删各跑一遍,看结论动不动 —— 这比直接删更有说服力。
|
|
||||||
- 论文要交代"异常值怎么处理"时,这是最容易被评审接受的判据(有文献、可复现、无随机性)。
|
|
||||||
|
|
||||||
**不用**:
|
|
||||||
|
|
||||||
- **别拿它当自动清洗**。偏态、幂律、小样本(`n < 20`)时 IQR 围栏会误杀 ——
|
|
||||||
重尾数据里"离群"常常就是信号本身。
|
|
||||||
- z-score 法**前提是近似正态**。右偏数据(收入、人口、成绩分布)用它会把整片上尾标红。
|
|
||||||
- 时间序列里的"离群"可能来自趋势或季节项,应先去趋势/差分再判,否则会把拐点当异常。
|
|
||||||
- 多变量离群(单变量看都正常、组合起来才异常)它看不出来,那要用马氏距离或 `IsolationForest`。
|
|
||||||
|
|
||||||
## 3. 参数与返回值
|
|
||||||
|
|
||||||
| 参数 | 类型 | 默认 | 含义 | 取值影响 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `data` | `DataFrame` / `Series` / `list` / `tuple` / `ndarray` | 必填 | 待判数据 | 非数值列不参与统计,掩码里恒 `False`(见下) |
|
|
||||||
| `method` | `"iqr"` / `"zscore"` | `"iqr"` | 判据 | IQR 对重尾稳健;zscore 只在近似正态时可靠 |
|
|
||||||
| `k` | `float` | `1.5` | 仅 IQR:围栏倍数 | 1.5 是 Tukey 的 "outlier"、3.0 是 "far outlier";调小 → 标出更多 |
|
|
||||||
| `threshold` | `float` | `3.0` | 仅 zscore:\|z\| 阈值 | 3.0 ≈ 正态下的 0.27%;调小 → 标出更多 |
|
|
||||||
|
|
||||||
`k` 与 `threshold` 通过 `**kwargs` 传入,**传错了不会报错**(`k` 只在 iqr 分支被读)。
|
|
||||||
|
|
||||||
### 为什么它不返回 `Result`
|
|
||||||
|
|
||||||
模板里"返回 `Result`"这一节对它**不适用**,这是刻意的:`Result` 承载的是"一次计算的
|
|
||||||
参数 + 指标 + 数据",而 `detect_outliers` 不改数据、不产生标量指标,它只是一个逐元素的
|
|
||||||
布尔判断。包成 `Result` 会多一层壳,调用方反而要写 `r.data` 才能拿到掩码。
|
|
||||||
|
|
||||||
返回值按输入形状分派:
|
|
||||||
|
|
||||||
| 输入 | 输出 |
|
|
||||||
|---|---|
|
|
||||||
| `DataFrame` | 同形布尔 `DataFrame` |
|
|
||||||
| `Series` | 布尔 `Series`,**列名保留** |
|
|
||||||
| 一维 `list` / `tuple` / `ndarray` | 一维布尔 `ndarray` |
|
|
||||||
| 二维 `ndarray` | 同形布尔 `DataFrame` |
|
|
||||||
|
|
||||||
### 非数值列为什么恒 `False`
|
|
||||||
|
|
||||||
两条备选路都被否掉了:
|
|
||||||
|
|
||||||
- **跳过**(把非数值列从掩码里去掉)→ docstring 承诺的"与原数据同形"就是假话,
|
|
||||||
形状对不上,也就没法按位置把掩码和原表对回去;
|
|
||||||
- **报错** → 一张带 `city` 列的真题表直接不可用,而 `city` 列本来就不该影响数值列判定。
|
|
||||||
|
|
||||||
`False` 的读法是"这一列没有离群点",对文本列而言这是最不坏的解释,
|
|
||||||
而且让整表能一次跑完。**但要注意**:这只是"不报错",
|
|
||||||
不代表文本列可以当数值列进模型 —— 编码是 `one_hot_encode` 的事。
|
|
||||||
|
|
||||||
## 4. 复杂度
|
|
||||||
|
|
||||||
- 时间:`O(n·p)`(n 行、p 列;分位数和均值各扫一遍,比较再扫一遍)。
|
|
||||||
- 空间:`O(n·p)` —— 掩码与原表同形,这是主要开销。
|
|
||||||
- **什么时候会跑不完**:单机几千万行以上时,同形掩码会把内存吃满
|
|
||||||
(1e7 行 × 20 列 `float64` 约 1.6 GB,掩码再来一份 `bool` 约 0.2 GB,
|
|
||||||
但 `quantile` 内部还会再复制)。真到那一步应该分块流式处理,而不是加内存。
|
|
||||||
|
|
||||||
## 5. 数值稳定性
|
|
||||||
|
|
||||||
- **`std == 0`(整列同值)**:代码里先 `std.replace(0, np.nan)`,让该列 z 全为 NaN、
|
|
||||||
一律判 `False`。少这一步会得到 `inf` / `0/0` 并伴随 RuntimeWarning。
|
|
||||||
- **NaN**:比较结果是 `False`,所以缺失值**不会**被标成离群。这是有意的 ——
|
|
||||||
缺失归 `clean_missing` / `impute_missing` 管,两件事不该混。
|
|
||||||
- **`n = 1`**:`std` 是 NaN、分位数等于该值本身,掩码全 `False`。小样本别指望它。
|
|
||||||
- **边界值**:`x_i` 恰好等于围栏值时**不算**离群(用的是严格 `<` / `>`)。
|
|
||||||
这是刻意选的:边界值不该被当异常。
|
|
||||||
- **`ddof` 口径**:本函数用 pandas 的 `Series.std()`,默认 `ddof = 1`(样本标准差),
|
|
||||||
而 `numpy.std` 默认 `ddof = 0`(总体标准差)。小样本下差别不小:`[1,2,3,4,1000]` 的
|
|
||||||
std 是 446.1(ddof=1)对 399.0(ddof=0),z 值直接差一成。**本项目 `stats.zscore` 的默认
|
|
||||||
也是 `ddof = 0`**,两处口径不一致是已知问题 —— 论文里报 |z| 时必须写明用的哪一个。
|
|
||||||
(分位数这边反而没问题:pandas `quantile` 与 `numpy.percentile` 默认都是线性插值,结果一致。)
|
|
||||||
|
|
||||||
## 6. 常见错误
|
|
||||||
|
|
||||||
1. 把返回值当筛选结果:`df = detect_outliers(df)` → 表变成布尔掩码。
|
|
||||||
正确做法是 `mask = detect_outliers(df)`,然后**自己决定**删不删。
|
|
||||||
2. 对含文本列的真题表直接调用 → 老版本在 `quantile()` 上崩。现在非数值列恒 `False`,
|
|
||||||
但要记得"不崩"不等于"文本列进了模型"。
|
|
||||||
3. 用 z-score 判右偏数据 → 整片上尾被标红,删完结论就变了。先画分布再选方法。
|
|
||||||
4. 看到离群就删 → 论文里没有交代、评审必问。先报告个数与占比,再做删/不删对照。
|
|
||||||
5. 只报"删了 5 个" → 没给比例、没给判据、没给 `k`。论文里这三个数缺一不可。
|
|
||||||
6. 以为要设 seed → 本函数**不含随机成分**;但后续若用 bootstrap 比较"删前后"的差异,
|
|
||||||
那条链必须 `set_seed`,否则"可复现"是假的。
|
|
||||||
|
|
||||||
## 7. 最小示例
|
|
||||||
|
|
||||||
```python
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from mcm_toolkit.data import detect_outliers
|
|
||||||
|
|
||||||
df = pd.DataFrame({"city": list("abcd"), "v": [1.0, 2.0, 3.0, 1000.0]})
|
|
||||||
|
|
||||||
mask = detect_outliers(df) # IQR,默认 k=1.5;只标不删
|
|
||||||
print(mask)
|
|
||||||
# city v
|
|
||||||
# 0 False False
|
|
||||||
# 1 False False
|
|
||||||
# 2 False False
|
|
||||||
# 3 False True
|
|
||||||
|
|
||||||
print(np.asarray(mask).sum()) # 1
|
|
||||||
print(detect_outliers(df["v"]).tolist()) # Series 进 Series 出
|
|
||||||
# [False, False, False, True]
|
|
||||||
|
|
||||||
# 想改成更激进的判定:k 调小
|
|
||||||
print(np.asarray(detect_outliers(df, k=0.5)).sum())
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 单元测试要点
|
|
||||||
|
|
||||||
| 测什么 | 期望 |
|
|
||||||
|---|---|
|
|
||||||
| 埋 3 个远离主体的点(IQR 默认) | 正好命中 3 个,且位置对得上 |
|
|
||||||
| 埋 1 个点(zscore 默认 t=3) | 正好命中 1 个 |
|
|
||||||
| 返回形状 | 与输入同形;Series 进 Series 出且列名保留;一维数组进一维布尔出 |
|
|
||||||
| 含文本列的表 | 不崩,文本列恒 `False`,数值列照常判定 |
|
|
||||||
| 整表没有数值列 | 不崩,返回全 `False` 掩码 |
|
|
||||||
| `method="bogus"` | `ValueError`,报错信息里带方法名 |
|
|
||||||
| `data = 42 / 3.14 / "123" / None / dict` | `TypeError`(§4.7:非法类型不静默兜底) |
|
|
||||||
| 全 NaN 列、单元素列 | 全 `False`,不抛异常 |
|
|
||||||
| 元组入参 | 按一维数组型处理,结果与传 list 一致 |
|
|
||||||
|
|
||||||
## 9. MCM/ICM 用法
|
|
||||||
|
|
||||||
- **哪类题**:所有要写"数据预处理"小节的题(几乎全部)。评价类与预测类尤其要做,
|
|
||||||
因为离群点对均值、回归系数的影响最大。
|
|
||||||
- **论文里怎么表述**:
|
|
||||||
"采用 Tukey (1977) 的 1.5×IQR 围栏识别异常值:记 Q1、Q3 为第一、三四分位数,
|
|
||||||
凡落在 [Q1 − 1.5·IQR, Q3 + 1.5·IQR] 之外者标为可疑点,共 N 个(占 x%)。
|
|
||||||
我们保留全部原始数据,并给出剔除/不剔除两版对照结果(见附录 B)。"
|
|
||||||
- **图**:箱线图(标出被标红的点)+ 直方图叠围栏竖线;用 `viz.plot(kind="box")`
|
|
||||||
与 `viz.plot(kind="hist")`,保存用 `viz.save_figure`。
|
|
||||||
- **踩坑提醒**:评审最可能追问三件事 ——
|
|
||||||
① 为什么是 1.5 而不是 3?② 删了多少、删完结论变了吗?③ 这些点是"错误"还是"真实极端值"?
|
|
||||||
三个问题都要在论文里先答,别等被问。
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# API 参考
|
|
||||||
|
|
||||||
**本目录不放手写 API 文档。** 手写的 API 文档一定会和代码脱节。
|
|
||||||
|
|
||||||
接口的权威来源是三个地方,按优先级:
|
|
||||||
|
|
||||||
1. **源码 docstring** —— 每个函数的数学含义、参数、返回值、Notes、示例都写在那里;
|
|
||||||
2. **`__all__`** —— 每个子包的公开 API 清单,`tests/test_api_surface.py` 会校验它和实际导出一致;
|
|
||||||
3. **`docs/02-architecture.md`** —— 跨函数的约定(`Result` 字段、RNG 规则、`fit/predict/score` 协议)。
|
|
||||||
|
|
||||||
需要生成站点时用 mkdocs + mkdocstrings(后者直接从 docstring 生成):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pip install mkdocs mkdocstrings[python]
|
|
||||||
mkdocs serve
|
|
||||||
```
|
|
||||||
|
|
||||||
**本仓库不把 mkdocs 放进依赖**——它是可选的、和比赛无关的工具。
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# S 档「函数卡片」模板
|
|
||||||
|
|
||||||
> **只给 S 档用**(约 15 个招牌算法,见 `docs/03-roadmap.md` §3.1)。
|
|
||||||
> 其余 A 档函数只要求 docstring 一句话 + 一条 pytest,不写卡片。
|
|
||||||
>
|
|
||||||
> 卡片写在哪:`docs/algorithms/<函数名>.md`。
|
|
||||||
> 为什么写:S 档是自己实现、以后会去改内部实现的算法,
|
|
||||||
> 卡片是"半年后还看得懂当时为什么这么写"的唯一保障。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 卡片模板
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# <函数名>
|
|
||||||
|
|
||||||
| 项 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| 模块 | `mcm_toolkit.<包>.<模块>` |
|
|
||||||
| 档位 | S |
|
|
||||||
| 参考来源 | <教材 / 论文 / 库;自己实现时参考了什么> |
|
|
||||||
|
|
||||||
## 1. 数学定义
|
|
||||||
|
|
||||||
<公式。用 LaTeX 写,例如 \`\\hat{\\theta}^* = \\arg\\min_\\theta ...\`。
|
|
||||||
一句话说清输入输出分别是什么数学对象。>
|
|
||||||
|
|
||||||
## 2. 适用场景
|
|
||||||
|
|
||||||
**用**:<什么题型、什么数据条件下该用它>
|
|
||||||
**不用**:<什么条件下它失效或不该用>
|
|
||||||
|
|
||||||
## 3. 参数与返回值
|
|
||||||
|
|
||||||
| 参数 | 类型 | 默认 | 含义 | 取值影响 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| | | | | |
|
|
||||||
|
|
||||||
返回 `Result`:
|
|
||||||
|
|
||||||
| 字段 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| `params` | |
|
|
||||||
| `metrics` | |
|
|
||||||
| `data` | |
|
|
||||||
|
|
||||||
## 4. 复杂度
|
|
||||||
|
|
||||||
- 时间:`O(...)`
|
|
||||||
- 空间:`O(...)`
|
|
||||||
- **什么时候会跑不完**:<给出具体数字量级,例如"n > 1e6 时内存 8GB 打不住">
|
|
||||||
|
|
||||||
## 5. 数值稳定性
|
|
||||||
|
|
||||||
<浮点误差、除零、log(0)、上溢下溢、病态矩阵……
|
|
||||||
写清"什么输入会出问题"和"代码里怎么防的"。>
|
|
||||||
|
|
||||||
## 6. 常见错误
|
|
||||||
|
|
||||||
1. <错误用法> → <后果> → <正确做法>
|
|
||||||
2. …
|
|
||||||
|
|
||||||
## 7. 最小示例
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 能直接复制进 notebook 跑通的 5~15 行
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 单元测试要点
|
|
||||||
|
|
||||||
| 测什么 | 期望 |
|
|
||||||
|---|---|
|
|
||||||
| <已知解析解> | <精度要求> |
|
|
||||||
| <边界输入> | <行为> |
|
|
||||||
|
|
||||||
## 9. MCM/ICM 用法
|
|
||||||
|
|
||||||
- **哪类题**:<例如"评价类 / 预测类 / 优化类">
|
|
||||||
- **论文里怎么表述**:<一句话,能直接改写成论文的"方法"小节>
|
|
||||||
- **图**:<配哪张图,用哪个 `viz` 函数>
|
|
||||||
- **踩坑提醒**:<评审最可能质疑什么>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 示例:`bootstrap_ci` 的卡片(示范,未定稿)
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# bootstrap_ci
|
|
||||||
|
|
||||||
| 项 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| 模块 | `mcm_toolkit.stats.bootstrap` |
|
|
||||||
| 档位 | S |
|
|
||||||
| 参考来源 | Efron (1979); Efron & Tibshirani (1993) *An Introduction to the Bootstrap* |
|
|
||||||
|
|
||||||
## 1. 数学定义
|
|
||||||
|
|
||||||
给定样本 \`x_1..x_n\` 与统计量 \`T(·)\`,从 \`x\` 中有放回抽 \`n\` 个得到 \`x^*\`,
|
|
||||||
重复 \`B\` 次得 \`T^*_1..T^*_B\`。百分位法区间为
|
|
||||||
\`[T^*_{(α/2·B)}, T^*_{(1-α/2)·B}]\`。
|
|
||||||
|
|
||||||
## 2. 适用场景
|
|
||||||
|
|
||||||
**用**:统计量没有解析分布(中位数、极差、自定义指标)、样本有偏、想给区间但不想假设正态。
|
|
||||||
**不用**:\`n < 10\`(经验分布不可靠);数据有强时间相关性时(要用 block bootstrap)。
|
|
||||||
|
|
||||||
## 3. 参数与返回值
|
|
||||||
|
|
||||||
| 参数 | 类型 | 默认 | 含义 | 取值影响 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `statistic` | callable | `np.mean` | 任意 `f(sample) -> float` | 换 `np.median` 就得到中位数的 CI |
|
|
||||||
| `n_resamples` | int | 10000 | 重采样次数 `B` | 越大区间端点越稳;10000 通常够 |
|
|
||||||
| `confidence` | float | 0.95 | 置信水平 | |
|
|
||||||
| `method` | str | `"percentile"` | `"percentile"` / `"bca"` | BCa 更准但慢 |
|
|
||||||
|
|
||||||
## 4. 复杂度
|
|
||||||
|
|
||||||
时间 \`O(B·n)\`;空间 \`O(B)\`(**按块处理,不落地 B×n 矩阵**)。
|
|
||||||
`n=1e6, B=1e4` 约 \`1e10\` 次操作 → **跑不完**,此时应改用解析近似或降 `B`。
|
|
||||||
|
|
||||||
## 7. 最小示例
|
|
||||||
|
|
||||||
\`\`\`python
|
|
||||||
import numpy as np
|
|
||||||
from mcm_toolkit.stats import bootstrap_ci
|
|
||||||
r = bootstrap_ci(heights, statistic=np.median, n_resamples=10000, random_state=42)
|
|
||||||
print(r.params["low"], r.params["high"])
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## 9. MCM/ICM 用法
|
|
||||||
|
|
||||||
- **哪类题**:任何要"给结论 + 给不确定性"的题,尤其评价类和预测类。
|
|
||||||
- **论文里怎么表述**:"采用非参数 bootstrap(B=10000,seed=42)估计中位数的 95% 置信区间。"
|
|
||||||
- **图**:预测曲线 + 阴影区间;用 `viz.plot`。
|
|
||||||
- **踩坑提醒**:必须报 `B` 和 seed;BCa 与 percentile 结果不同时要说明选了哪个及理由。
|
|
||||||
```
|
|
||||||
@@ -1,562 +0,0 @@
|
|||||||
# 个人易错点(只追加)
|
|
||||||
|
|
||||||
> **本文件的规矩**
|
|
||||||
> 1. **只追加。** 已有条目一个字都不改、不重排、不润色。发现写错了 → 在末尾补一条"更正",不要回头改。
|
|
||||||
> 2. **不通读。** 追加前只做两件事:`rg -i "<关键词>" docs/dev/personal-pitfalls.md` 看有没有记过;
|
|
||||||
> 看一眼下面的索引表最后一行拿编号。全文只在复盘时才读。
|
|
||||||
> 3. 一条写三行:**现象** / **为什么会错** / **以后怎么防**。
|
|
||||||
> 写不出"为什么会错"就先别记——那说明还没想清楚。
|
|
||||||
> 4. 记**规律**,不记事故。"今天又忘了"不新开条目,在已有条目下把 `复发` 加一。
|
|
||||||
|
|
||||||
## 追加模板
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
### N. <一句话>
|
|
||||||
- 现象:
|
|
||||||
- 为什么会错:
|
|
||||||
- 以后怎么防:
|
|
||||||
- 首次:YYYY-MM-DD|复发:0
|
|
||||||
```
|
|
||||||
|
|
||||||
## 索引
|
|
||||||
|
|
||||||
| # | 一句话 | 首次 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | 类型判断只写 `int`,漏掉 `np.int64` 这类 numpy 标量 | 2026-10-01 |
|
|
||||||
| 2 | 以为 `# TODO(L0)` 只是普通注释 | 2026-10-01 |
|
|
||||||
| 3 | 签名里的类型注解和 docstring 说的不一致 | 2026-10-01 |
|
|
||||||
| 4 | 只覆盖自己的状态,忘了第三方库还在用 numpy 旧全局 | 2026-10-01 |
|
|
||||||
| 5 | 参数校验排在副作用(建目录 / 写文件)之后 | 2026-10-01 |
|
|
||||||
| 6 | 把 lint 的"没报错"当成"没问题" | 2026-10-01 |
|
|
||||||
| 7 | 写"守卫子句"时,忘了不是所有入参都长一样 | 2026-10-02 |
|
|
||||||
| 8 | 改了一处,忘了同源的另一处 | 2026-10-02 |
|
|
||||||
| 9 | 实现写完了,测试还挂着 skip —— 绿灯是假的 | 2026-10-02 |
|
|
||||||
| 10 | 把真报错当成"编辑器误报" | 2026-10-02 |
|
|
||||||
| 11 | pandas 对不齐就静默填 NaN,不报错 | 2026-10-03 |
|
|
||||||
| 12 | 三个并列的 `if`,后面的静默覆盖前面的 | 2026-10-03 |
|
|
||||||
| 13 | `Index == 标量` 得到布尔数组,`if` 要等到第 2 列才炸 | 2026-10-03 |
|
|
||||||
| 14 | 把"能看的表"和"能算的表"塞进同一个返回值 | 2026-10-03 |
|
|
||||||
| 15 | 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 参数写反 | 2026-10-03 |
|
|
||||||
| 16 | 同一段入口转换抄了 N 遍,还当它是"本项目的写法" | 2026-10-03 |
|
|
||||||
| 17 | `hasattr(f, "__name__")` 挡不住 lambda,`else` 分支是死代码 | 2026-10-03 |
|
|
||||||
| 18 | 入口校验"想起什么写什么",没照 §4.7 清单逐条过 | 2026-10-04 |
|
|
||||||
| 19 | 只想着主路径那种值,忘了布尔 / NaN / 长度不对 | 2026-10-04 |
|
|
||||||
| 20 | "不简单"的信号:同一个信息要在调用方写两遍 | 2026-10-04 |
|
|
||||||
| 21 | 公式里的 `^` 不是乘方,而且被 `except` 吞成 success=False | 2026-10-05 |
|
|
||||||
| 22 | 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功" | 2026-10-05 |
|
|
||||||
| 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 |
|
|
||||||
| 24 | 默认参数路径从没被执行过 —— 默认采样器用了库不接受的参数 | 2026-10-05 |
|
|
||||||
| 25 | 两个维度相等时,转置的形状检查会静默通过 | 2026-10-05 |
|
|
||||||
| 26 | `except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸 | 2026-10-05 |
|
|
||||||
| 22 | 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功" | 2026-10-05 |
|
|
||||||
| 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1. 类型判断只写 `int`,漏掉 `np.int64` 这类 numpy 标量
|
|
||||||
|
|
||||||
- 现象:`get_rng(np.int64(42))` 不报错,但种子被静默忽略、退回全局流——结果不可复现,且没有任何提示。
|
|
||||||
- 为什么会错:`np.int64` **不是** python `int` 的子类(`isinstance(np.int64(42), int)` 是 `False`),
|
|
||||||
但 `np.float64` **是** `float` 的子类。numpy 标量的继承关系不统一,凭直觉一定会翻车。
|
|
||||||
- 以后怎么防:写类型分支时默认假设"这个值可能来自 numpy"。判断用 `isinstance(x, (int, np.integer))`,
|
|
||||||
取值统一 `int(x)` 归一化;并且给所有非预期类型加 `raise TypeError` 兜底——
|
|
||||||
**宁可报错,也不要静默走默认分支**。
|
|
||||||
- 复发(2026-10-04):`monte_carlo` 的 `n` 只写了 `isinstance(n, int)` —— `np.int64(1000)` 被 `ValueError` **误拒**,
|
|
||||||
而 `n=True` 反而放行(`bool` 是 `int` 子类,判断顺序又反了)。
|
|
||||||
同一张 §4.7 表的第一、三行,症状从"静默兜底"变成"误拒 + 误收":**规则没换,只是没去查**(见第 18 条)。
|
|
||||||
- 首次:2026-10-01|复发:0 → **1(2026-10-04)**
|
|
||||||
|
|
||||||
### 2. 以为 `# TODO(L0)` 只是普通注释
|
|
||||||
|
|
||||||
- 现象:函数实现完了,`# TODO(L0)` 还挂在末尾。
|
|
||||||
- 为什么会错:本仓库把 `TODO(L0)` / `TODO(L1)` / `TODO(v0.2)` 当**进度标记**用:
|
|
||||||
`docs/03-roadmap.md` 靠它划分档位,`STATUS.md` 靠它统计"还剩哪些没做"。实现完不删,等于骗自己。
|
|
||||||
- 以后怎么防:实现完成时顺手删掉该函数里的 `TODO(...)`。
|
|
||||||
全仓还剩多少没做,一条 `rg "TODO\(" src/` 就能数出来。
|
|
||||||
- 首次:2026-10-01|复发:0
|
|
||||||
|
|
||||||
### 3. 签名里的类型注解和 docstring 说的不一致
|
|
||||||
|
|
||||||
- 现象:`def get_rng(random_state: int | None = None)`,docstring 却写"已经是 `np.random.Generator` 时原样返回"。
|
|
||||||
- 为什么会错:类型注解是给**读代码的人和 pyright** 看的契约。注解说只收 int/None、docstring 说还收 Generator,
|
|
||||||
两处打架时合法用法会被 IDE 标红,真正的错误反而看不出来。
|
|
||||||
- 以后怎么防:改签名和改 docstring 必须同一次做完。
|
|
||||||
本项目所有 `random_state` 的注解统一写 `int | np.integer | np.random.Generator | None`。
|
|
||||||
- 复发(2026-10-05):`monte_carlo_probability` 写 `event: Callable[..., bool]`,docstring 却说"返回长度为 `n` 的**布尔数组**"
|
|
||||||
—— 注解只描述"返回一个 bool"。pyright 实测:这样写会**拒绝**任何注解良好的向量化事件函数(`ndarray` 与 `bool` 不兼容),
|
|
||||||
而运行期它一直都收得下。同一个坑从"收什么"换到了"返回什么":**注解是契约,不是备注**。
|
|
||||||
- 首次:2026-10-01|复发:1 → **2(2026-10-05)**
|
|
||||||
|
|
||||||
### 4. 只覆盖自己的状态,忘了第三方库还在用 numpy 旧全局
|
|
||||||
|
|
||||||
- 现象:`set_seed` 换掉了本包的生成器,但 sklearn / statsmodels 里 `random_state=None` 的路径
|
|
||||||
仍不可复现(测试直接挂)。
|
|
||||||
- 为什么会错:numpy 有**两套**随机状态——旧的 `np.random.*` 全局单例,和新的 `Generator`。
|
|
||||||
第三方库大量仍在走旧的那套(`check_random_state(None)`),自己新建一个 Generator 管不到它们。
|
|
||||||
- 以后怎么防:凡是"设全局状态"的函数,先列清单——自己的、numpy 旧的、标准库 `random` 的、
|
|
||||||
第三方框架的,逐个决定要不要一起设,并在 docstring 里写清到底覆盖了哪几个。
|
|
||||||
- 首次:2026-10-01|复发:0
|
|
||||||
|
|
||||||
|
|
||||||
### 5. 参数校验排在副作用(建目录 / 写文件)之后
|
|
||||||
|
|
||||||
- 现象:`save_table` 先 `path.parent.mkdir(parents=True, exist_ok=True)`,再在
|
|
||||||
`if suffix == '.csv' ... else: raise ValueError` 里判断后缀。传 `sub/x.txt` 时,
|
|
||||||
空目录 `sub\` 已经建出来了,函数才抛 `ValueError`——错误路径上留下垃圾。
|
|
||||||
- 为什么会错:函数体是照着"顺利流程"顺着写下来的——先建目录、再按分支干活;
|
|
||||||
校验顺手塞进分支的 `else` 里,于是校验自动排到了最后。分支越多,越会有路径漏掉。
|
|
||||||
会传染:`isinstance` 校验改到 `mkdir` 前面之后,后缀校验**还是**留在原地,
|
|
||||||
因为它藏在分支里,看起来"已经检查过了"。
|
|
||||||
- 以后怎么防:函数体分三段写——**① 所有校验 → ② 所有副作用(建目录 / 写文件 /
|
|
||||||
联网 / 改全局状态)→ ③ 主逻辑**。校验用守卫子句集中在开头,不要塞进分支里;
|
|
||||||
凡是 `raise` 的行,位置必须在校验段内,不能出现在副作用段之后。
|
|
||||||
写完自问一句:"这个参数不合法时,磁盘 / 网络 / 全局状态会不会已经动过了?"
|
|
||||||
- 首次:2026-10-01|复发:0
|
|
||||||
|
|
||||||
### 6. 把 lint 的"没报错"当成"没问题"
|
|
||||||
|
|
||||||
- 现象:`__all__` 与下一个 `def` 之间只留了 1 个空行(PEP 8 要求 2 个),
|
|
||||||
`ruff check src tests` 照样打印 "All checks passed!"。
|
|
||||||
- 为什么会错:版本号看着一样,覆盖的规则却是两回事。ruff 0.16 里 `E302`
|
|
||||||
(函数间空行)属于 **preview 规则**,不在 `select = ["E"]` 的默认集合里——
|
|
||||||
加了也会被"because preview is not enabled"忽略掉,安安静静不报。
|
|
||||||
根本问题是:**lint 只检查你 select 的那部分**,而默认规则集本来就不是 PEP 8 全集。
|
|
||||||
把"我配的检查全过了"读成"这份代码符合规范",是把自己没开的检查当成了已经通过的检查。
|
|
||||||
- 以后怎么防:把 lint 结论限定成"我 select 的规则里没问题",别说成"没问题"。
|
|
||||||
想看全貌就偶尔跑一次 `ruff check --select ALL --preview`(只当参考,别直接开进 CI,
|
|
||||||
会淹没在噪声里);纯格式问题交给 `ruff format`,它是 formatter,不受 `select` 影响。
|
|
||||||
- 首次:2026-10-01|复发:0
|
|
||||||
|
|
||||||
### 7. 写"守卫子句"时,忘了不是所有入参都长一样
|
|
||||||
|
|
||||||
- 现象:`winsorize` 开头先算 `numeric_cols = data.select_dtypes(include=['number']).columns`
|
|
||||||
挡"空数值列",但 `winsorize` 同时收 `DataFrame` 和 `Series` ——
|
|
||||||
**`Series` 上没有 `select_dtypes`**,传 Series 直接 `AttributeError`,
|
|
||||||
而且报错就报在守卫那一行(全函数看起来最"安全"的地方)。
|
|
||||||
- 为什么会错:写的时候只想"要防住空数值列",顺手把守卫放在函数最上面,
|
|
||||||
没回头确认**这个函数还收哪些类型**。守卫本身没错,错的是它的**位置**:
|
|
||||||
它对 `DataFrame` 成立、对 `Series` 根本不合法,所以必须排在
|
|
||||||
`isinstance(data, pd.Series)` 的类型分派**之后**。
|
|
||||||
本质是:**先做类型分派,再写只对其中一支成立的逻辑**。
|
|
||||||
- 以后怎么防:写"开头集中校验"之前,先把入参的**全部类型**列一遍,
|
|
||||||
逐条问"这句话对每种类型都成立吗";不成立的往后挪,
|
|
||||||
挪进 `if isinstance(...)` 里面,让它只有对应的那一支能走到。
|
|
||||||
- 首次:2026-10-02|复发:0
|
|
||||||
|
|
||||||
### 8. 改了一处,忘了同源的另一处
|
|
||||||
|
|
||||||
- 现象:给 `detect_outliers` 加"文本列不参与"的修复时,只把**掩码写入**收窄到数值列
|
|
||||||
(`mask[numeric_cols] = ...`),却让 `data.quantile()` / `data.mean()` / `data.std()`
|
|
||||||
继续在全表上算。测试照样红 —— 文本列在**算统计量**那一步就崩了,跟掩码怎么写没关系。
|
|
||||||
- 为什么会错:眼睛盯着"结果往哪放"(掩码),没回头问"这些数是从哪儿来的"。
|
|
||||||
同一份数据在函数里要过两道加工:**先出统计量(分位数 / 均值 / 标准差),再拿去比较**。
|
|
||||||
只把后一道收窄、前一道还是全表,等于没修。
|
|
||||||
修完测试报告里那条仍是 XFAIL,就是这个信号 —— 报告没变绿,说明根因没抓着。
|
|
||||||
- 以后怎么防:修"某处不该参与计算"这类问题时,顺着数据从入到出**走一遍**,
|
|
||||||
把该收窄的地方一次列全,别改完第一处就收手;
|
|
||||||
并且以测试信号(XFAIL → XPASS)为准,别以"我改的那一行看起来对了"为准。
|
|
||||||
- 首次:2026-10-02|复发:0
|
|
||||||
|
|
||||||
### 9. 实现写完了,测试还挂着 skip —— 绿灯是假的
|
|
||||||
|
|
||||||
- 现象:`transform.py` 四个函数都写完了,`pytest` 一片绿(`64 passed`),
|
|
||||||
但 `test_normalize_minmax_range` / `test_standardize_has_zero_mean_unit_std` 两条
|
|
||||||
还挂着 `@pytest.mark.skip(reason=_NOT_IMPLEMENTED)` —— 新代码**一条测试都没跑到**。
|
|
||||||
顺带还漏掉一个真错:`normalize` 少了兄弟函数都有的类型守卫,`normalize(None)` 静默返回 NaN。
|
|
||||||
- 为什么会错:`skip` 是"还没写"的意思,写完实现后该由**写的人**把它摘掉;
|
|
||||||
但摘 mark 这个动作不在"写函数"的肌肉记忆里,跑一次 `pytest` 看到全绿就以为收工了。
|
|
||||||
这是决策 26 埋下的反面:逐条 skip 比模块级 skip 精确,但**数量得自己盯**。
|
|
||||||
- 以后怎么防:每次写完实现,用 `pytest -q -rs`(或 `--collect-only`)**核对 skip 条数掉没掉**;
|
|
||||||
条数没变就说明实现没被验证过。另一个信号是 `pytest --collect-only` 的收集数 ——
|
|
||||||
新写的测试没进收集列表,就是名字或位置不对。
|
|
||||||
- 复发(2026-10-03):`bootstrap_ci` 写完了,`tests/test_stats.py` 里等它的 3 条仍然挂着 skip,
|
|
||||||
全量照样 134 passed / 37 skipped 全绿 —— 连 `_NOT_IMPLEMENTED` 这个常量名都没提示你。
|
|
||||||
- 首次:2026-10-02|复发:0 → **1(2026-10-03)**
|
|
||||||
|
|
||||||
### 10. 把真报错当成"编辑器误报"
|
|
||||||
|
|
||||||
- 现象:`transform.py` 改完后编辑器(Pylance)在 `pd.DataFrame(result, columns=x.columns,
|
|
||||||
index=x.index)` 两行画红线,第一反应是"静态检查又误报了,挂个 `# type: ignore` 就完了"。
|
|
||||||
实际跑一遍:**pyright CLI 报同样的 2 个错**(Pylance 就是 pyright 的内核),
|
|
||||||
而且 `pytest` **7 条红**(`normalize` 的三种 method 全走错、`one_hot_encode` 把编码列整列丢掉)。
|
|
||||||
红线只是**症状**,病在代码里。
|
|
||||||
- 为什么会错:编辑器把错误显示在**它看得见的那一行**,而那一行的类型确实和来源不符;
|
|
||||||
但"类型不符"既可能是检查器保守(误报),也可能是**上游真的算错了**。
|
|
||||||
不验证就下"误报"结论,等于用一个假设替代一次检查。
|
|
||||||
- 以后怎么防:见到红线先做两件事,再谈 `# type: ignore` ——
|
|
||||||
① 在**终端**跑同引擎的 CLI(`python -m pyright`),排除编辑器缓存/配置差异;
|
|
||||||
② 跑 `pytest`,看对应的行为断言红不红。两条都过、确认只是检查器保守推断,
|
|
||||||
才在**最小范围**加 `# type: ignore[具体错误码]`;裸 `# type: ignore` 会连带关掉后面所有行
|
|
||||||
(更好的写法见 `docs/04-environment.md` 坑 8 的 `cast`)。
|
|
||||||
- 首次:2026-10-02|复发:0
|
|
||||||
|
|
||||||
### 11. pandas 对不齐就静默填 NaN,不报错
|
|
||||||
|
|
||||||
- 现象:`describe` 里为了"行 = 变量、列 = 指标"多做了一次 `.T`,然后
|
|
||||||
`table.loc["missing"] = df_num.isna().sum()`。跑起来**一声不响**,表里却多出四行
|
|
||||||
**全 NaN**(`missing` / `missing_ratio` / `skewness` / `kurtosis`)。
|
|
||||||
`.loc[新行标签] = Series` 是按**列名**对齐的:Series 的索引是变量名、表的列名是
|
|
||||||
指标名,两边**没有一个交集**,pandas 于是把整行填成 NaN —— 不抛异常。
|
|
||||||
同一个 `.T` 让 `weights` 分支换成 ndarray 后反而直接报
|
|
||||||
`ValueError: cannot set a row with mismatched columns` —— 那个报错才是救命的。
|
|
||||||
- 为什么会错:脑子里想的是"给这一行赋值",pandas 做的是"按标签对齐再赋值"。
|
|
||||||
对齐失败在 pandas 里是**合法结果**(就是缺失),不是错误 —— 它不会替你判断
|
|
||||||
"这次对齐失败是不是手滑"。
|
|
||||||
- 以后怎么防:① 往表里加一行前,先确认 Series 的索引 == 目标轴的标签(列名),
|
|
||||||
拿不准就 `print(list(series.index))` 对着 `list(table.columns)` 看一眼;
|
|
||||||
② 新加的行/列**立刻断言不是全 NaN**,别只看"代码跑过去了";
|
|
||||||
③ 记住这次的信号差异:长度不对(ndarray)会报错,Series **不会** —— 后者更危险。
|
|
||||||
- 首次:2026-10-03|复发:0
|
|
||||||
|
|
||||||
### 12. 三个并列的 `if`,后面的静默覆盖前面的
|
|
||||||
|
|
||||||
- 现象:`normalize` 里三种 method 写成了**三个并列的 `if`**(而不是 `if / elif / else`):
|
|
||||||
`if method == "minmax": result = ...`、`if method == "vector": result = ...`,
|
|
||||||
末尾再来一句无条件的 `result = arr / x_max` 兜住 max。
|
|
||||||
读起来像"三种方法各算各的",实际是**三段顺序执行、逐次覆盖**同一个 `result`:
|
|
||||||
不管传 minmax 还是 vector,算完都被最后那段冲掉 —— 三种方法**实际只走了 max**。
|
|
||||||
docstring 里那行 `>>> normalize([1, 2, 3]).tolist()` → `[0.0, 0.5, 1.0]` 就是活规格,
|
|
||||||
实测出来是 `[0.33, 0.67, 1.0]`,5 条测试一起红。
|
|
||||||
- 为什么会错:脑子里想的是"按 method 分派",手上写成了"按顺序执行"。
|
|
||||||
写第一个 `if` 的时候没意识到"少了 `elif` 就没有互斥",而 `result` 又是同一个变量 ——
|
|
||||||
`if` 只会让**条件为真时**赋值,`elif`/`else` 才负责让**其余分支不执行**。
|
|
||||||
这类错**不报错、不抛异常**,只有断言能抓。
|
|
||||||
- 以后怎么防:① 同一个变量被多个分支赋值时,一律 `if / elif / else`,或让每个分支 `return`,
|
|
||||||
绝不写并列 `if`;② 分派逻辑写完先跑最朴素的那个用例(docstring 的 Examples 就是最好的靶子),
|
|
||||||
别等全量测试;③ 注意 ruff 的 `RET505` 抓的是**反方向**(`return` 后还有 `elif`),
|
|
||||||
并列 `if` 覆盖它完全管不着 —— **lint 不会替你发现这个**。
|
|
||||||
- 同一轮的另一个同类例子见第 11 条(`.loc` 对齐失败静默填 NaN):
|
|
||||||
这两个 bug 的共同点是"**不抛异常**",靠的是断言而不是报错。
|
|
||||||
- 首次:2026-10-03|复发:0
|
|
||||||
|
|
||||||
### 13. `Index == 标量` 得到布尔数组,`if` 要等到第 2 列才炸
|
|
||||||
|
|
||||||
- 现象:`correlation_matrix` 里的判空写成了 `if numeric_cols == 0:`,本意是"一个数值列都没有就报错"。
|
|
||||||
实际行为分成三段,**每一段都不一样**:
|
|
||||||
- **0 列**:`array([], dtype=bool)` → `bool(...)` 是 `False` → **静默不报错**,带着空表继续往下跑;
|
|
||||||
- **1 列**:`array([False])` → 单元素数组能转 `bool` → **碰巧正常**;
|
|
||||||
- **≥2 列**:`array([False, False, ...])` → `ValueError: The truth value of an array
|
|
||||||
with more than one element is ambiguous` → **直接崩**。
|
|
||||||
而"≥2 个数值列"恰恰是最常见的情况,所以这一行等于让整个函数不可用。
|
|
||||||
- 为什么会错:`Index` 重载了 `==`,做的是**逐元素比较**,返回的是**数组**不是 `bool`。
|
|
||||||
脑子里比的是"这个序列是不是空的",手上写的是"这个序列的每个元素是不是 0"。
|
|
||||||
更阴的是 1 列时它能跑通 —— 拿单列试一下就会以为这行没问题。
|
|
||||||
- 没被抓住的原因:对应的 L0 用例当时还挂着 `skip`(见第 9 条),所以实现零覆盖。
|
|
||||||
- 以后怎么防:① 判"空"一律 `len(x) == 0`(或 `.empty`),绝不用 `== 0`;
|
|
||||||
② 判断容器里有没有东西,用 `if not len(...)` 而**不是** `if x:` ——
|
|
||||||
`if x:` 对 `DataFrame` / `Index` / 多元素 `ndarray` 会抛同一个歧义错误;
|
|
||||||
③ 新写的分支,**至少拿 0 / 1 / 多三种形状各跑一遍**,单列能过不代表多列能过。
|
|
||||||
- 首次:2026-10-03|复发:0
|
|
||||||
|
|
||||||
### 14. 把"能看的表"和"能算的表"塞进同一个返回值
|
|
||||||
|
|
||||||
- 现象:`correlation_matrix` 返回的是一张**带显著性星标的字符串表**(`1.000***` 这种),
|
|
||||||
直接贴论文很爽。但同一个函数在 docstring 里承诺的是"值 = 相关系数",
|
|
||||||
L0 用例也断言 `np.allclose(np.diag(...), 1.0)` —— 字符串表两样都做不到:
|
|
||||||
拿到结果的人**没法**挑高相关对、喂给聚类 / PCA、画热力图。
|
|
||||||
于是这条用例只能先挂 `xfail`,实现和契约顶在那里。
|
|
||||||
- 为什么会错:写的时候想的是"我最后要看到什么"(论文里的那张表),
|
|
||||||
没想"调用方还要拿它**算什么**"。展示需求是**输出端**的事,不该侵占返回值。
|
|
||||||
一旦返回字符串,数值信息就**不可逆地丢了**(你可以随时把数字格式化成字符串,
|
|
||||||
但没法把 `"0.570***"` 还原成 `0.570` 和小数点后丢失的精度)。
|
|
||||||
- 以后怎么防:① 一个函数只返回**一种东西** —— 要么是数据,要么是给人看的展示;
|
|
||||||
② 判据是"这个返回值还能不能参与下一步计算":不能,就该拆成两个函数;
|
|
||||||
③ 拆的时候让**计算型**当主函数、**展示型**去调它(`correlation_table` 调
|
|
||||||
`correlation_matrix` + `correlation_pvalues`),而不是反过来。
|
|
||||||
- 首次:2026-10-03|复发:0
|
|
||||||
|
|
||||||
### 15. 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 的参数写反了
|
|
||||||
|
|
||||||
- 现象:`confidence_interval` 的返回写成了
|
|
||||||
`return (float(min(0.0, lower)), float(max(upper, 1.0)))`,本意是"比例区间不能越界"。
|
|
||||||
三个毛病叠在一起:
|
|
||||||
① **参数写反**:要夹下界应该写 `max(0, lower)`,写成了 `min(0.0, lower)` —— `0.0` 比任何正的下界都小,
|
|
||||||
`min` 取到的永远是它,于是**下界恒为 0**;上界那条 `max(upper, 1.0)` 同样是反的,
|
|
||||||
只是它在 `upper > 1` 时凑巧看不出问题。
|
|
||||||
② **无条件夹**:三种 method(含 `normal` / `t` 这两种**均值**区间)全被夹。
|
|
||||||
均值区间的量纲根本不在 `[0, 1]` 里:实测
|
|
||||||
`confidence_interval(rng.normal(170, 8, 50), method="t")` 的下界是 **0.0**,
|
|
||||||
而正确结果是 `171.0 ± 2.1`。这种错**不抛异常**,只有把数字打印出来才会发现"身高的下界是 0"。
|
|
||||||
③ **夹子会吞掉报错信号**:`confidence=1.5` 时 `z` 是 `nan`,`min(0.0, nan)` 返回 `0.0`
|
|
||||||
(`min` 在遇到 `nan` 之前先锁定了第一个参数),于是本该报错的入参被盖成 `(0.0, nan)`。
|
|
||||||
另外 `confidence=0` 也一路放行(定义域应是 `(0, 1)` **开区间**)。
|
|
||||||
- 为什么会错:脑子里记的是"clamp(x, lo, hi)"这个整体,手上却从两个方向各写了一个 `min` / `max`;
|
|
||||||
更要命的是**试数据选了 `[1, 2, 3, 4, 5]`** —— 它恰好落在 `[0, 1]` 附近,夹不夹都看不出来。
|
|
||||||
换一个量纲明显不在 `[0, 1]` 的例子(身高 170、GDP 十亿)跑第一遍,这个 bug 一秒现形。
|
|
||||||
- 以后怎么防:① 夹子只写一个方向:`np.clip(x, lo, hi)`,或 `max(lo, min(hi, x))`;
|
|
||||||
不要手写两个 `min` / `max` —— 它们看起来对称,其实一个管下界一个管上界,最容易写反。
|
|
||||||
② **写"区间"函数先做量纲检查**:拿一个边界外的数值当输入,结果被夹到 0 / 1 就是 bug。
|
|
||||||
③ 边界参数一律按开区间校验(`0 < confidence < 1`),并用 `np.isnan` 兜住参数异常产生的 nan。
|
|
||||||
④ 记住 `min` / `max` **不传播 nan、结果还取决于参数顺序**(`min(0.0, nan)` → `0.0`、
|
|
||||||
`min(nan, 0.0)` → `nan`)—— "夹一下"就能把 nan 悄悄换成边界值。
|
|
||||||
- 首次:2026-10-03|复发:0
|
|
||||||
|
|
||||||
### 16. 同一段入口转换抄了 N 遍,还当它是"本项目的写法"
|
|
||||||
|
|
||||||
- 现象:`normalize` / `standardize` / `log_transform` / `zscore` / `confidence_interval` / `t_test`
|
|
||||||
六个函数各自抄了同一段 5~8 行:白名单 `isinstance` → `is_df` / `is_series` → 抓 `columns` / `index`
|
|
||||||
→ `np.asarray`。这类判断一度散在 **5 个文件 31 处**。抄到第三遍时已经不觉得是重复了,
|
|
||||||
反而觉得"这就是本项目的入参写法"。
|
|
||||||
- 为什么会错:重复代码每次只多 3~5 行,看着不痛;但每多一处就多一次**口径分叉**的机会 ——
|
|
||||||
而且真的分叉了:`normalize` 拿整表 `np.asarray`(遇文本列直接抛)、`zscore` 用 `select_dtypes`
|
|
||||||
只取数值列、`confidence_interval` 还多一个 `.ravel()`。同一件事三种口径,出 bug 时你还得先判断
|
|
||||||
"这是哪个副本的行为"。
|
|
||||||
- 以后怎么防:① 同一段入参处理**抄到第 2 遍**就该抽,别等第 3 遍;
|
|
||||||
② 抽之前先问一句"输出的形状是不是和输入一样"——**入口能统一、出口不能一刀切**
|
|
||||||
(`describe` / `correlation_*` 的输出天生不同形,硬套还原会重演第 14 条);
|
|
||||||
③ 抽出来的函数只干**一件事**:把容器转成数组、并告诉调用方原来是什么。
|
|
||||||
选列 / 摊平 / 删缺失这些是**语义**,必须留在各函数里(否则重演第 15 条与 `paired=True` 的 NaN 错位)。
|
|
||||||
- 首次:2026-10-03|复发:0
|
|
||||||
|
|
||||||
### 17. `hasattr(f, "__name__")` 判断"是不是具名函数"—— lambda 也有 `__name__`
|
|
||||||
|
|
||||||
- 现象:`bootstrap_ci` 想给 `metrics["statistic_name"]` 一个名字,写成了
|
|
||||||
`statistic.__name__ if hasattr(statistic, '__name__') else "custom"`。
|
|
||||||
本意是"认得的内置统计量给名字、自定义的标 custom",实际传 lambda 时得到的是
|
|
||||||
**字符串 `"<lambda>"`** —— 因为 lambda 有 `__name__`,只是值恒为 `"<lambda>"`。
|
|
||||||
也就是说 `else` 那支**几乎永远走不到**,是死代码。
|
|
||||||
- 为什么会错:`__name__` 在脑子里等于"函数的名字",于是 `hasattr` 就等于"这是个函数"。
|
|
||||||
实际上 `__name__` 是**属性存在性**,不是"名字有意义":lambda、`functools.partial` 包过的函数、
|
|
||||||
`numpy` 的 ufunc(`np.mean.__name__` 是 `"mean"`,能过)各有各的情况,
|
|
||||||
真正拿不到的只有**没有 `__name__` 的可调用对象**(比如带 `__call__` 的类实例)。
|
|
||||||
同类陷阱还有 `hasattr(x, "__len__")` 判容器(`str` 也有)、`hasattr(x, "__iter__")` 判列表。
|
|
||||||
- 以后怎么防:① 判"是不是我想要的那类函数",用**显式白名单**(`if statistic in [np.mean, np.median, ...]`),
|
|
||||||
别用属性存在性推断语义;② 真要取名字就写成
|
|
||||||
`name = getattr(f, "__name__", None)` 再判断 `name in (None, "<lambda>")`,
|
|
||||||
把"没有名字"和"名字是占位符"两种情况都覆盖;③ 每个 `else`/兜底分支都问一句
|
|
||||||
"**这条到底测得到吗**"——走不到的分支就是 bug 伪装成的防御性代码。
|
|
||||||
- 首次:2026-10-03|复发:0
|
|
||||||
|
|
||||||
### 18. 入口校验"想起什么写什么",没有照着清单逐条过
|
|
||||||
|
|
||||||
- 现象:`monte_carlo` 的入口只写了"n 是整数、n 为正",没写"那 `n=True` / `n=np.int64` / `n=10.0` 各走哪条"。
|
|
||||||
结果 `np.int64(1000)` 被误拒、`n=True` 被放行、浮点 `n` 报的是 `ValueError` 而不是 `TypeError`。
|
|
||||||
§4.7 那张判断顺序表早就写死了,写代码时一次都没翻。
|
|
||||||
- 为什么会错:校验是"防御性代码",写的时候满脑子是主路径("次数当然是整数"),
|
|
||||||
**不会自然想到 `True`、`np.int64`、`None`、0 这些"合法类型里的例外值"**。
|
|
||||||
清单存在的意义正是替代这种直觉——但清单要主动去查才有用;不查就等于没有。
|
|
||||||
同类:第 1 条(只写 `int`)、第 7 条(守卫子句的位置)、`random_state=None` 绕开 `get_rng`(决策 46)。
|
|
||||||
- 以后怎么防:每个公开函数写完,把 §4.7 的表当 checklist **逐行过一遍**
|
|
||||||
(int + np.integer / bool 排在 int 前 / float + np.floating / 数组先 `np.asarray`),
|
|
||||||
再用三个探针各打一枪:`f(x=np.int64(1))`、`f(x=True)`、`f(x=1.0)`。
|
|
||||||
**这一步是本人的活**(决策 49):要不要放宽、报什么错、算不算合法,都是取舍,不是机械活。
|
|
||||||
- 复发(2026-10-05):`random_walk` 的 `n_steps = 2.5` / `n_steps = True` 又都走 `ValueError("必须是正整数")` ——
|
|
||||||
同模块的 `monte_carlo` 早就是"类型不对 `TypeError`、取值不对 `ValueError`"。表就在那儿,还是没翻。
|
|
||||||
- 首次:2026-10-04|复发:**1(2026-10-05)**
|
|
||||||
|
|
||||||
### 19. 只想着主路径上的那种值,忘了同一位置还会出现别的类型 / 值
|
|
||||||
|
|
||||||
- 现象:`monte_carlo` 的 `simulator` 返回**布尔**数组("事件是否发生",MC 最常见的用法)时,
|
|
||||||
`np.asarray(...)` 原样保留 bool,`np.std` 在 numpy 2 上直接抛
|
|
||||||
`numpy boolean subtract ... is not supported`,整个函数判失败;字符串结果则给出
|
|
||||||
"ufunc isnan not supported"这种看不懂的话。同一轮还漏了:结果里含一个 NaN 时 8 个指标全 NaN 却报 `success=True`。
|
|
||||||
- 为什么会错:写的时候脑子里只有"年收益 = 浮点数"这一种结果,
|
|
||||||
"0/1 事件"、"NaN"、"长度对不上"都不在主路径上。而**这个函数恰恰是这几类输入的高发区**
|
|
||||||
(事件概率、模型发散、批量大小写错),所以它们不是边角料,是常规用法。
|
|
||||||
- 以后怎么防:写完一个"吃调用方数组"的函数,除了主路径,按五类各跑一次:
|
|
||||||
**布尔 / 整数 / 含 NaN / 全相同(常量)/ 长度不对**。
|
|
||||||
类型归一化放在**入口一处**做(`np.asarray(x, dtype=float)`),后面所有指标就都安全了——
|
|
||||||
这正是 §4.7 表最后一行与 §4.8 存在的理由。
|
|
||||||
- 首次:2026-10-04|复发:0
|
|
||||||
|
|
||||||
### 20. "不简单"的最早信号:同一个信息要在调用方写两遍
|
|
||||||
|
|
||||||
- 现象:第一版 `monte_carlo` 的契约是 `sampler(rng, **kwargs)`,但采样器**拿不到 n**,
|
|
||||||
于是调用方必须写 `n=1000, size=1000` —— 同一个数写两遍,还要靠 `len(data) != n` 事后兜底。
|
|
||||||
改成实现自己传 `sampler(rng, size=n, **kwargs)` 之后,调用方只写 `n=1000`。
|
|
||||||
同一处的后遗症:`**kwargs` 里再传 `size` 会撞成 `got multiple values for keyword argument`。
|
|
||||||
- 为什么会错:设计签名时只想着"参数全靠 `**kwargs` 透传"这一条好处(灵活),
|
|
||||||
没数"调用方为了让它跑起来还得额外写什么"。
|
|
||||||
**重复的那一个数是症状,不是问题**:真正的问题是"批量大小"这个信息在接口里**没有归属**。
|
|
||||||
- 以后怎么防:写完签名,立刻写出**最小调用示例**,然后数两件事:
|
|
||||||
① 有没有哪个信息写了两遍;② 有没有哪一步"只有我知道"。有 → 把它吸收进实现,而不是写进文档。
|
|
||||||
简单 ≠ 参数少,而是**调用方不用重复表达**。
|
|
||||||
- 首次:2026-10-04|复发:0
|
|
||||||
|
|
||||||
### 21. 把数学公式抄进代码时,`^` 当成乘方 —— 还被 `except` 吞成 `success=False`
|
|
||||||
|
|
||||||
- 现象:`monte_carlo_probability` 的 Wilson 区间里写 `z_score^2 / n`。Python 的 `^` 是**按位异或**,
|
|
||||||
对 `np.float64` 直接抛 `TypeError: ufunc 'bitwise_xor' not supported`;因为整段计算在
|
|
||||||
`try/except Exception` 里,对外只表现为 `success=False` + 一句 message ——
|
|
||||||
最后是 6 条测试全红(`KeyError: 'ci_high'`)才把它挖出来。
|
|
||||||
- 为什么会错:纸上的 `z²`、`x²` 抄进代码时手会自动敲 `^`(数学记号、Excel、LaTeX 都是这个符号),
|
|
||||||
而 Python 的乘方是 `**`。更险的是 `^` 在**整数**上是合法异或:`3^2` 得到 1、不报错,只算错。
|
|
||||||
第二层原因是 `try/except Exception` 把"代码写错"和"数据不合法"混成一个出口,
|
|
||||||
错误现场(哪一行、哪个异常)被压成一句字符串,静态检查也看不见(pyright 当时 0 error)。
|
|
||||||
- 以后怎么防:① 公式里的幂一律写 `**`(或 `np.power`),见到 `^` 就当 bug 处理;
|
|
||||||
② 新函数写完先**手动跑一次正常输入**,`success` 必须是 `True` —— 这条一跑就现形;
|
|
||||||
③ 给算法配一条**独立实现的对照测试**(Wilson 对 `statsmodels.proportion_confint(method="wilson")`),
|
|
||||||
这类"公式抄错"会被直接钉死;④ 记住 `except Exception` 越宽,错误越难看见。
|
|
||||||
- 首次:2026-10-05|复发:0
|
|
||||||
|
|
||||||
### 22. 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功"
|
|
||||||
|
|
||||||
- 现象:`monte_carlo_integral` 里内层 `monte_carlo` 已经返回 `success=False`(`func` 返回了标量),
|
|
||||||
但包装层接着无条件执行 `result.message = "蒙特卡洛多重积分成功:…"`。对外就是 `success=False` + 一句"成功"文案。
|
|
||||||
- 为什么会错:把 `result` 当成"自己的返回值"直接美化,忘了它是**可能失败**的通道。
|
|
||||||
`success` 和 `message` 是同一份状态的两个视图,分开写就一定有机会不一致 —— 与第 12 条(后面的 `if` 静默覆盖前面的)同源。
|
|
||||||
- 以后怎么防:包装函数里只改"成功才会有意义"的字段(`name` / `params` / `metrics`),改 `message` 一律包在
|
|
||||||
`if result.success:` 里;或者只**追加**、不覆盖。写完先跑一次**注定失败**的输入,看对外那两个字段是否自洽。
|
|
||||||
- 首次:2026-10-05|复发:0
|
|
||||||
|
|
||||||
### 23. 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住
|
|
||||||
|
|
||||||
- 现象:多重积分里体积写成 `wights = lows - highs`(名字打错 + 减反)。奇数维(1D、3D)估计值全为负、
|
|
||||||
而 **2D 看起来完全正常**(`∫∫ x·y` 得 +0.2499,正确值 0.25)—— 两个负号相乘抵消,把错误藏住了。
|
|
||||||
- 为什么会错:① "上界减下界"被顺手写成了"参数顺序相减",而变量名 `wights` 又不像 `lows` / `highs` 那样自带方向提示;
|
|
||||||
② 验算时只挑了一个二维例子(乘法的对称性恰好掩盖了符号),**维度是奇是偶没进脑子**。
|
|
||||||
同源教训见第 19 条(只想着主路径那种值)。
|
|
||||||
- 以后怎么防:长度/宽度/体积一律写 `high - low`,并在心里念一遍"大的减小的";
|
|
||||||
验算**至少各挑一个奇数维和一个偶数维**(1D + 3D 都能一秒现形);
|
|
||||||
拿到 `mean` 之后先跟解析值比**符号**,再看数值大小。
|
|
||||||
- 首次:2026-10-05|复发:0
|
|
||||||
|
|
||||||
### 24. 默认参数路径从没被执行过 —— 默认采样器用了库不接受的参数
|
|
||||||
|
|
||||||
- 现象:`random_walk` 的默认步长采样器写 `rng.integers(0, 2, size=size, dtype=float)`。numpy 的 `Generator.integers`
|
|
||||||
**不收浮点 dtype**(`TypeError: Unsupported dtype dtype('float64') for integers`),于是 `random_walk(100, 5)`
|
|
||||||
这种最普通的调用 100% 返回 `success=False`、`data=None`;连 docstring 里那条 `>>> r.data.shape (5, 101)` 都跑不通。
|
|
||||||
- 为什么会错:写的时候验的是**自定义采样器**那条分支,默认分支(`step_sampler is None`)一次都没执行过;
|
|
||||||
更巧的是 `try/except Exception` 把它压成了一句 message,看起来像"输入不合法"而不是"代码写错了"。
|
|
||||||
与第 21 条同源:**错误现场被 `except` 藏起来**,静态检查(pyright)也看不见。
|
|
||||||
- 以后怎么防:新函数写完,**第一个跑的就是"什么参数都不填的默认调用"**,`success` 必须是 `True`;
|
|
||||||
再把 docstring 的 Examples 真执行一遍(它们是最便宜的一组冒烟测试)。
|
|
||||||
凡是用到"库函数 + 一串可选参数"的地方,别凭记忆写,跑一次看它收不收。
|
|
||||||
- 首次:2026-10-05|复发:0
|
|
||||||
|
|
||||||
### 25. 两个维度恰好相等时,转置的形状检查会静默通过
|
|
||||||
|
|
||||||
- 现象:`random_walk` 把 `size=(n_paths, n_steps)` 交给 `step_sampler`,但没校验它返回的形状。
|
|
||||||
实测一个返回**转置**形状 `(n_steps, n_paths)` 的采样器:`n_paths=6, n_steps=4` 时报错(好事),
|
|
||||||
而 `n_paths=n_steps=5` 时 `success=True`、`data` 形状 `(5, 6)` 也"对"—— 里面的路径却是转置过来的,**静默错**。
|
|
||||||
- 为什么会错:形状检查依赖"两个维度不相等"这个前提才生效;`cumsum(axis=1)` + 赋值广播在方阵上完全合法。
|
|
||||||
验算时只挑了一组 `n_paths == n_steps` 的参数,正好落在唯一发现不了的那个点上。
|
|
||||||
与第 23 条同一个思路的不同侧面:**对称/相等会把错误藏起来**。
|
|
||||||
- 以后怎么防:验算参数**把两个维度取成不同值**(`n_paths=3, n_steps=100` 这种),别用正方形;
|
|
||||||
对外部传进来的批量数组显式校验 `shape == (n, d)`,别指望后面的运算替你把关。
|
|
||||||
- 首次:2026-10-05|复发:0
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 26. `except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸
|
|
||||||
|
|
||||||
- 现象:`random_walk` 把 `call_params = {...}` 写在 `try:` 里面,但 `except` 分支要 `params=call_params`。
|
|
||||||
于是**每一个失败路径**都变成 `UnboundLocalError: cannot access local variable 'call_params' where it is not associated with a value`
|
|
||||||
—— 实测 5 种失败输入(形状不对 / 增量含 NaN / 采样器抛异常 / `start` 非数值 / 返回 1-D 数组)**5 个全中**,
|
|
||||||
本该返回 `success=False` 的 `Result`,变成了把异常直接抛给调用方。
|
|
||||||
- 为什么会错:写的时候脑子里只过"成功路径",`except` 是照上一版的结构留下来的;
|
|
||||||
**异常分支只有真抛异常才会执行**,正常跑一遍、ruff、pyright 全都看不见(当时两个都 0 error)。
|
|
||||||
与第 24 条(默认路径没跑过)是同一类:**分支覆盖靠"我以为",不靠执行**。
|
|
||||||
还有一层:`params` 里那几个值(`n_steps` / `n_paths` / `start` / `random_state`)在 `try` 之前就已经定了,
|
|
||||||
它们本来就**不属于**"可能失败的计算",放进去纯属位置错误。
|
|
||||||
- 以后怎么防:① 写 `except` 时,逐个变量问"它是在 `try` 里才被赋值的吗"——**失败的返回要在 `try` 之前准备好**;
|
|
||||||
② 每写一个 `try`,必须配一个**注定失败**的用例(`assert r.success is False`),专门喂 `except` 分支;
|
|
||||||
③ 包装层的失败结果也要带 `params`,出问题才追得回去。
|
|
||||||
- 首次:2026-10-05|复发:0
|
|
||||||
|
|
||||||
### 27. 入口按类型分成两条路,两条路的校验口径互相矛盾
|
|
||||||
|
|
||||||
- 现象:`markov_simulate` 的 `initial` 用 `isinstance(initial, int)` 分流。标量那条按**路径数**铺满
|
|
||||||
(`np.full(n_paths, initial)`),数组那条却按**状态数**校验(`len(initial) != s` 报错),
|
|
||||||
而真正写进去的是 `paths[:, 0] = current_states`,长度要等于**路径数**。于是"数组 + `n_paths != s`"
|
|
||||||
这条组合永远跑不通;`n_paths == s` 时又能跑通 —— **同一个入口,两个维度各管一半**。
|
|
||||||
实测 `initial=[0.5, 0.5], n_paths=5` 直接在 `try` 外抛 `ValueError`(本该 `success=False`)。
|
|
||||||
- 为什么会错:写校验时是"顺着刚写完的那一行"补的 —— 标量分支当年是为了铺满路径,数组分支是照着"转移矩阵的状态数"
|
|
||||||
顺手写的,两边各自看着都对。**没有回到参数本身的语义上问一句"它到底是按谁计数"**。
|
|
||||||
同族:`np.int64(0)` 掉进数组分支(`isinstance` 只写了 `int`,易错点 1)、`True` 被当状态 1 静默放行。
|
|
||||||
- 以后怎么防:① 一个参数有多条类型分支时,**把每条分支的"计数维度"写在一张纸上**(按路径数?按状态数?按列数?),
|
|
||||||
对不上就说明口径没定清,**先定语义再写代码**;② 分流用的 `isinstance` 必须把 `np.integer` / `bool` 一起想到(易错点 1);
|
|
||||||
③ 任何"写进结果数组"的赋值,问一遍"它的长度约束和上面校验用的是同一个下标维度吗"。
|
|
||||||
- 首次:2026-10-05|复发:0
|
|
||||||
|
|
||||||
### 28. 数值阈值参数的两个漏网:`bool` 和 NaN
|
|
||||||
|
|
||||||
- 现象:`markov_steady_state` 的 `tol` 只写了 `if not isinstance(tol, (int, float)) or tol <= 0`。
|
|
||||||
`tol=True` → `isinstance(True, int)` 为真、`True > 0` 也为真 → 阈值变成 `1`,
|
|
||||||
**第 1 次迭代就"收敛"**,返回 `[0.55, 0.45]` 这种假答案,而且 `success=True, converged=True`。
|
|
||||||
`tol=np.nan` → `nan <= 0` 为假 → 穿过校验 → 白跑满 `max_iter` 次迭代。
|
|
||||||
同一个函数里 `max_iter` 查了 `bool`、`tol` 没查 —— 说明是**逐条手动补的**,不是照着清单过的。
|
|
||||||
- 为什么会错:脑子里"阈值 = 一个正数",于是校验只写"是数 & 大于 0";而 `bool` 是 `int` 的子类、
|
|
||||||
NaN 与任何数比较都是 `False`,这两条都恰好从"是数 & 大于 0"里漏出去。**与易错点 1 同根**,
|
|
||||||
只是这次漏的是"比较"而不是"类型判断"。
|
|
||||||
- 同族(同一轮发现的):只校验"转移矩阵行和 = 1.0"就以为矩阵合法 —— `[[1.5, -0.5], [0, 1]]` 行和也是 1.0,
|
|
||||||
但含负元素,迭代时 `v` 指数发散、最后 `data=[nan, nan]` 却 `success=True`。
|
|
||||||
**"行和 = 1"不等于"是概率矩阵"**,还要 `arr >= 0`。另外这类溢出会抛 `RuntimeWarning`,
|
|
||||||
而本项目 `filterwarnings = ["error::RuntimeWarning"]` —— **测试会直接报错**,别指望测试能兜住静默错。
|
|
||||||
- 以后怎么防:① 校验数值阈值时,脑子里过一遍"三件套":**类型(含 `bool` / `np.integer` 例外)、
|
|
||||||
有限性(`np.isfinite`,挡 NaN / inf)、取值区间**;② 同一函数里同类参数(`max_iter` / `tol` / `n` / `tol`)
|
|
||||||
用**同一套模板**写,别一个查 `bool` 一个不查;③ 校验"概率/权重"这类输入时,除了和为 1,还要问一句"有没有可能是负数/NaN"。
|
|
||||||
- 首次:2026-10-09|复发:0
|
|
||||||
|
|
||||||
### 29. 同一个校验里的两把尺子:一个宽容、一个精确
|
|
||||||
|
|
||||||
- 现象:`markov_simulate` / `markov_steady_state` 校验转移矩阵时有两条:
|
|
||||||
`if not np.allclose(arr.sum(axis=1), 1.0)`(**宽容**,默认 `atol=1e-8`)和 `if np.any(arr < 0)`(**精确**)。
|
|
||||||
实测 `[[1.0, -1e-16], [0.5, 0.5]]`:行和都是 1.0,但左边那行有个 `-1e-16` 的归一化残渣 →
|
|
||||||
被判"含负元素"直接报错。**这个矩阵在数学上没问题,是校验太严**。
|
|
||||||
- 为什么会错:两条校验是分两次、照着不同念头写的 —— 行和那条想着"浮点比较不能写 `==`",
|
|
||||||
非负那条想着"概率怎么能为负"。各自都对,**放在一起就成了一严一宽**。建模时矩阵常常是
|
|
||||||
`df / df.sum()` 归一化出来的,这类 `-1e-17` 残渣非常常见,比赛现场会直接卡住。
|
|
||||||
- 同族(同一轮里一起犯的):① 边界比较与文档口径不一致 —— `mm1_metrics` 写 `service_rate < arrival_rate`,
|
|
||||||
而 docstring 与测试都写 `λ >= μ` 不稳定,于是 `λ == μ` 掉进正常计算分支、`rho = 1` →
|
|
||||||
`ZeroDivisionError`(结果碰巧对,message 却是"计算失败:ZeroDivisionError");
|
|
||||||
② 一批报错文案是复制粘贴的,**参数名忘了改** —— 同一轮里 `max_iter` 的文案写 "tol"、
|
|
||||||
`service_rate` 的两条文案也都写 "tol"。两条都是"看着像对的、跑起来也行、但口径/文案是错的"。
|
|
||||||
- 以后怎么防:① 同一个量的多条校验,**先把容差定下来再写**(要么都用 `np.allclose` / 小 `eps`,要么都精确),
|
|
||||||
写完回头对一眼"这几条的严格程度一致吗";② 阈值类比较(`<` / `<=`)**对着 docstring 读一遍**,
|
|
||||||
特别是 == 落在哪边;③ 复制粘贴报错文案时,逐个把参数名念出来。
|
|
||||||
- 首次:2026-10-09|复发:0
|
|
||||||
|
|
||||||
### 30. 改了口径(返回值 / 键名 / 分支),下游三处会同时过期
|
|
||||||
|
|
||||||
- 现象:`markov_simulate` 这一轮把 `metrics` 从 `std_terminal` 换成 `terminal_distribution`、
|
|
||||||
`mm1_metrics` 的不稳定分支从 `metrics={}` 变成 `metrics={"rho"}`、`arrival_rate=0` 从"报错"变成"合法"。
|
|
||||||
代码本身没问题,但**同一轮的测试 5 条全红**(都在断言旧键名 / 旧分支),docstring 的 `Returns` 也还在写 `std_terminal`。
|
|
||||||
- 为什么会错:改的时候注意力全在"新行为对不对",**看不见的契约**(测试断言、docstring、STATUS 的口径表)没人提醒你。
|
|
||||||
这三处过期不会报错,只会"文档说 A、代码做 B",过几天自己都分不清哪个是准的。
|
|
||||||
- 以后怎么防:① 动返回值 / 键名 / 分支前,先 `rg` 一下这个名字在全仓的出现位置(`rg "std_terminal" `),
|
|
||||||
把"要一起改的地方"列出来再动手;② 改完立刻跑 `pytest` —— 红的测试就是"过期契约"的清单;
|
|
||||||
③ docstring 的 `Returns` / `Raises` 属于**契约的一部分**,和代码同一次改完,别留到"下次顺手"。
|
|
||||||
- 首次:2026-10-10|复发:0
|
|
||||||
|
|
||||||
### 31. 公式里的"基准量"抄错了对象,而且完美拟合会把错掩盖掉(R² 的 SS_tot)
|
|
||||||
|
|
||||||
- 现象:`models/metrics.py` 写 `ss_tol = np.sum((yt - np.mean(yp)) ** 2)` —— 分母的基准该是 `y_true` 的均值,
|
|
||||||
写成 `y_pred` 的均值了。实测(`y_true=[1,2,3,4,5]`、`y_pred=[1.5,2.5,2.5,4.5,5.5]`):错版 **0.8803827751196172**,
|
|
||||||
正确版 **0.875** = `sklearn.metrics.r2_score`。**而 `r2_score(y, y)` 照样返回 1**,常量 y 那条照样报错 ——
|
|
||||||
两种最常见的测试输入都盖住了这个错。
|
|
||||||
- 为什么会错:R² 的分子用 `y_pred`、分母用 `y_true` 的均值,两个变量名只差一个字母(`yt` / `yp`),
|
|
||||||
是从同一个表达式复制过来的。这种错"看起来像对、跑起来不报错、只有对着数据才露馅"。
|
|
||||||
- 以后怎么防:① 写完公式**逐个念一遍每个量的"是谁的"**(这个均值是 `y_true` 的还是 `y_pred` 的?);
|
|
||||||
② 指标类函数至少测一条**非完美拟合**的数据(完美拟合 / 常量 / 全 0 都是"天然掩盖 bug"的输入);
|
|
||||||
③ 拿成熟库当**独立参照物**(`sklearn.metrics.r2_score`、`scipy.stats`),别自己算自己;
|
|
||||||
④ 变量名别用 `ss_tol`(无意义),写 `ss_tot`(total sum of squares)—— 名字说得清,错法就少一半。
|
|
||||||
- 首次:2026-10-10|复发:0
|
|
||||||
|
|
||||||
### 32. `np.where` 的掩码防不住零除(两条分支都会被求值)
|
|
||||||
|
|
||||||
- 现象:`smape` 写 `np.where(denom == 0, 0.0, 2 * |yt - yp| / denom)`。分母为 0 时**数值结果是对的**(记 0),
|
|
||||||
但 `RuntimeWarning: invalid value encountered in divide` 仍会漏出来;本项目 `pyproject.toml` 开了
|
|
||||||
`filterwarnings = ["error::RuntimeWarning"]`,于是任何"含 0 的 smape"测试**直接判失败**。
|
|
||||||
- 为什么会错:`np.where` 是"**先把两条分支都算好,再按掩码挑**",不是 `if / else` 的短路求值。
|
|
||||||
"结果对" 和 "没有副作用" 是两件事,告警就是那个副作用。
|
|
||||||
- 以后怎么防:① 除法一律写 `np.divide(a, b, out=..., where=掩码)`(或临时 `with np.errstate(invalid="ignore")`),
|
|
||||||
不要拿 `np.where` 当挡箭牌;② 写完拿最脏的输入跑一遍(全 0、单个点、常量列),**盯着 warning 看**;
|
|
||||||
③ 项目开了 warning-as-error 时,"不告警"就是契约的一部分,要有一条测试专门钉住它。
|
|
||||||
- 首次:2026-10-10|复发:0
|
|
||||||
|
|
||||||
### 33. 长报错文案拆行时少一个引号 / 多一个逗号(`ruff` 一秒能抓,别等运行时)
|
|
||||||
|
|
||||||
- 现象:两条都在同一轮里写出来 —— ① `raise TypeError(` 下面的长中文 f-string 拆成两行,第一行**忘写结尾引号**,
|
|
||||||
Python 直接 `SyntaxError: unterminated f-string literal`(`ruff` 报 `invalid-syntax`,`import` 也跑不了);
|
|
||||||
② `raise ValueError(
|
|
||||||
f"...",
|
|
||||||
"..."
|
|
||||||
)` —— 想拼接两段文案却写成**两个参数**,
|
|
||||||
message 就成了元组的字符串 `('...', '...')`,看着像"报错文案有点怪",很难一眼看出是参数个数的问题。
|
|
||||||
- 为什么会错:中文文案长,按习惯拆行;`+` / 隐式拼接 / 逗号在编辑器里长得差不多。
|
|
||||||
这类错**不是逻辑错**,是"手滑",但会在最不该花时间的地方浪费两分钟。
|
|
||||||
- 以后怎么防:① 写完多行字符串,**立刻**跑一次 `ruff check`(或 `python -c "import ..."`)—— 语法错它必抓;
|
|
||||||
② 相邻字符串字面量的隐式拼接**不要夹逗号**,要拼接就老老实实写 `+` 或一次写完;
|
|
||||||
③ `Raises` 里放的不是长句而是"参数名 + 实际值",本身就不用拆行。
|
|
||||||
- 首次:2026-10-12|复发:0
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# 教程
|
|
||||||
|
|
||||||
按"题型"组织的实战教程放这里,每篇对应一类赛题。
|
|
||||||
|
|
||||||
**目前为空是刻意的**:教程应该在**做过至少一道该类型的真题之后**再写,
|
|
||||||
否则写出来的都是"字典式罗列",没有踩坑经验,没有价值。
|
|
||||||
|
|
||||||
计划(做完对应真题后再写):
|
|
||||||
|
|
||||||
| 教程 | 对应题型 | 触发条件 |
|
|
||||||
|---|---|---|
|
|
||||||
| `评价类.md` | 无量纲化 → 赋权 → TOPSIS/灰色 → 敏感性 | 做完一道评价类真题 |
|
|
||||||
| `预测类.md` | 描述 → 分布拟合 → ARIMA/GM(1,1) → 区间 → 误差分析 | 做完一道预测类真题 |
|
|
||||||
| `优化类.md` | 建模 → LP/IP → 启发式 → 多目标 → 敏感性 | 做完一道优化类真题 |
|
|
||||||
| `仿真类.md` | 参数分布 → Monte Carlo → 收敛性 → 鲁棒性 | 做完一道仿真类真题 |
|
|
||||||
|
|
||||||
面向"怎么用这个工具箱"的快速上手看 [`../04-environment.md`](../04-environment.md) 和
|
|
||||||
仓库根目录的 `README.md`;这里放的是**建模思路**。
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# conda 环境复现文件(换机器 / 精确重建时用)。
|
|
||||||
#
|
|
||||||
# 它需要联网从 repo.anaconda.com 下载。断网时不要用它 —— 正确做法是
|
|
||||||
# **比赛前就把环境建好**,并保留 D:\anaconda\pkgs 缓存。
|
|
||||||
# 注意 conda create --clone base 在 conda 25.11 上不是"离线本地复制",
|
|
||||||
# 实测会重放 base 的完整包列表并逐个下载,非常慢。
|
|
||||||
#
|
|
||||||
# 实测(2026-10-01):conda create 精准包列表约 14.5 分钟,下载约 3 GB,
|
|
||||||
# 环境本体 1.55 GB。详见 docs/04-environment.md。
|
|
||||||
#
|
|
||||||
# 用法:
|
|
||||||
# conda env create -n mcm-toolkit -f environment.yml
|
|
||||||
# conda env update -n mcm-toolkit -f environment.yml --prune
|
|
||||||
|
|
||||||
name: mcm-toolkit
|
|
||||||
|
|
||||||
channels:
|
|
||||||
- defaults
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
# 解释器:与 2026-10 本机 Anaconda base 一致(3.13.9)
|
|
||||||
- python=3.13
|
|
||||||
|
|
||||||
# 核心四件套(pyproject.toml 里的必装依赖)
|
|
||||||
- numpy>=2.0
|
|
||||||
- scipy>=1.14
|
|
||||||
- pandas>=2.2
|
|
||||||
- matplotlib>=3.9
|
|
||||||
|
|
||||||
# [ml]
|
|
||||||
- scikit-learn>=1.5
|
|
||||||
- statsmodels>=0.14
|
|
||||||
|
|
||||||
# [graph]
|
|
||||||
- networkx>=3.3
|
|
||||||
|
|
||||||
# [symbolic]
|
|
||||||
- sympy>=1.13
|
|
||||||
|
|
||||||
# [excel]
|
|
||||||
- openpyxl>=3.1
|
|
||||||
|
|
||||||
# [dev]
|
|
||||||
- pytest>=8.0
|
|
||||||
- ruff>=0.6
|
|
||||||
|
|
||||||
# pip 部分:conda 通道里没有或版本不合的
|
|
||||||
- pip
|
|
||||||
- pip:
|
|
||||||
- SALib>=1.4 # [sensitivity] Sobol / Morris
|
|
||||||
- pymoo>=0.6 # [moo] 多目标优化
|
|
||||||
- pyright>=1.1 # [dev] 类型检查
|
|
||||||
- -e . # 本项目(可编辑安装)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""端到端最小流程:读数据 → 清洗 → 描述 → bootstrap → 出图。
|
|
||||||
|
|
||||||
体量刻意压到最小,只用来确认"环境 + 包 + 出图"这条链路是通的。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
import mcm_toolkit as mcm
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
mcm.set_seed(20260101)
|
|
||||||
|
|
||||||
# 1) 造一份带缺失和离群点的数据(真实赛题里这步是 load_table)
|
|
||||||
rng = np.random.default_rng(20260101)
|
|
||||||
heights = rng.normal(170, 8, size=300)
|
|
||||||
heights[[3, 77]] = [260.0, -10.0] # 两个录入错误
|
|
||||||
|
|
||||||
# 2) 找出离群点(约定:只标不删,删不删是人来决定)
|
|
||||||
mask = np.asarray(mcm.data.detect_outliers(heights, method="iqr"))
|
|
||||||
print(f"检出离群点 {mask.sum()} 个")
|
|
||||||
|
|
||||||
clean = heights[~mask]
|
|
||||||
|
|
||||||
# 3) 描述统计
|
|
||||||
print(mcm.stats.describe(clean))
|
|
||||||
|
|
||||||
# 4) 中位数的 bootstrap 置信区间(统计量没有解析分布时的通用答案)
|
|
||||||
ci = mcm.bootstrap_ci(clean, statistic=np.median, n_resamples=10000, random_state=20260101)
|
|
||||||
print(f"中位数 = {np.median(clean):.2f}, 95% CI = [{ci.params['low']:.2f}, {ci.params['high']:.2f}]")
|
|
||||||
|
|
||||||
# 5) 出图(默认同时写 png + pdf,300 dpi)
|
|
||||||
mcm.viz.mcm_style()
|
|
||||||
fig, ax = mcm.viz.plot(clean, kind="hist", title="身高分布", xlabel="cm")
|
|
||||||
paths = mcm.viz.save_figure(fig, "figures/quickstart_hist")
|
|
||||||
print("已写出:", [str(p) for p in paths])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""data + stats 最小用法:读 → 清 → 补 → 判离群 → 缩尾 → 描述。
|
|
||||||
|
|
||||||
每一步都对应 `tests/test_data.py` 里的一条用例;体量刻意压到最小。
|
|
||||||
`stats.describe` 收尾:行 = 指标、列 = 变量,可直接贴进论文。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
import mcm_toolkit as mcm
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
mcm.set_seed(20260101)
|
|
||||||
|
|
||||||
# 1) 假装这是从 Excel 导出的赛题数据:带文本列、带缺失、带两个录入错误
|
|
||||||
df = pd.DataFrame(
|
|
||||||
{
|
|
||||||
"city": ["A", "B", "C", "D", "E", "F"],
|
|
||||||
"gdp": [1000.0, 1100.0, np.nan, 1300.0, 1400.0, 9999.0],
|
|
||||||
"pop": [10.0, 11.0, np.nan, np.nan, 20.0, 15.0],
|
|
||||||
"notes": ["ok", "ok", np.nan, "ok", np.nan, "异常"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
path = Path(tmp) / "demo.csv"
|
|
||||||
|
|
||||||
# 2) 写盘再读回:load_table 按扩展名分派,.csv 会自动探测编码
|
|
||||||
mcm.data.save_table(df, path)
|
|
||||||
loaded = mcm.data.load_table(path)
|
|
||||||
print(f"读回 {loaded.shape[0]} 行 × {loaded.shape[1]} 列")
|
|
||||||
|
|
||||||
# 3) 缺失太多的行先删:缺失比例 > 0.5 的走人(4 列,阈值 0.5 -> 至少要 2 个非空)
|
|
||||||
kept = mcm.data.clean_missing(loaded, axis=0, threshold=0.5)
|
|
||||||
print(f"按缺失比例删行:{loaded.shape[0]} -> {kept.shape[0]}")
|
|
||||||
|
|
||||||
# 4) 剩下的缺失做填补。mean 只作用于数值列,notes 列原样保留缺失
|
|
||||||
filled = mcm.data.impute_missing(kept, method="mean")
|
|
||||||
print("填补后每列缺失数:", filled.isna().sum().to_dict())
|
|
||||||
|
|
||||||
# 5) 找离群点:只标不删,删不删由人决定
|
|
||||||
mask = mcm.data.detect_outliers(filled, method="iqr", k=1.5)
|
|
||||||
print("检出离群点:")
|
|
||||||
print(mask)
|
|
||||||
print(f"共 {int(mask.sum().sum())} 个;notes 列恒 False(非数值列不参与统计)")
|
|
||||||
|
|
||||||
# 6) 不删也能用:缩尾把极端值压到分位点上,而不是丢掉整行
|
|
||||||
w = mcm.data.winsorize(filled, lower=0.01, upper=0.99)
|
|
||||||
print(f"gdp 最大值 {filled['gdp'].max():.1f} -> 缩尾后 {w['gdp'].max():.1f}")
|
|
||||||
|
|
||||||
# 7) 描述统计:行 = 指标、列 = 变量,可直接贴进论文
|
|
||||||
table = mcm.stats.describe(filled[["gdp", "pop"]])
|
|
||||||
print(table.round(2))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""蒙特卡洛:估计 π、积 x²、算概率,并画收敛曲线。
|
|
||||||
|
|
||||||
三个例子覆盖 MC 的三种典型用法:
|
|
||||||
积分(几何量)、概率估计、以及"n 要取多大才够"的收敛性验证。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from mcm_toolkit.solve import monte_carlo_integral, monte_carlo_probability
|
|
||||||
from mcm_toolkit.viz import mcm_style, plot_convergence, save_figure
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
mcm_style()
|
|
||||||
|
|
||||||
# --- 1) 估 π:单位正方形内落入四分之一圆的比例 × 4 ---
|
|
||||||
def in_circle(X: np.ndarray) -> np.ndarray:
|
|
||||||
return (X**2).sum(axis=1) <= 1.0
|
|
||||||
|
|
||||||
r = monte_carlo_probability(in_circle, lambda rng, **k: rng.uniform(-1, 1, size=(1, 2)), n=200_000, random_state=1)
|
|
||||||
print(f"π ≈ {4 * r.metrics['probability']:.5f} (真值 3.14159)")
|
|
||||||
|
|
||||||
# --- 2) 积分 ∫₀¹ x² dx = 1/3 ---
|
|
||||||
r2 = monte_carlo_integral(lambda X: X[:, 0] ** 2, bounds=[(0, 1)], n=200_000, random_state=2)
|
|
||||||
print(f"∫₀¹x²dx ≈ {r2.data:.5f} (真值 0.33333)")
|
|
||||||
|
|
||||||
# --- 3) 收敛性:MC 误差是 O(1/√n),论文里要给这张图证明 n 选得够 ---
|
|
||||||
ns = np.unique(np.round(np.logspace(2, 6, 40)).astype(int))
|
|
||||||
errors = []
|
|
||||||
for n in ns:
|
|
||||||
est = monte_carlo_integral(lambda X: X[:, 0] ** 2, bounds=[(0, 1)], n=int(n), random_state=7)
|
|
||||||
errors.append(abs(est.data - 1 / 3))
|
|
||||||
|
|
||||||
fig, _ = plot_convergence(
|
|
||||||
np.column_stack([ns, errors]),
|
|
||||||
log=True,
|
|
||||||
reference=None,
|
|
||||||
title="Monte Carlo 误差随样本量收敛",
|
|
||||||
xlabel="样本量 n",
|
|
||||||
ylabel="绝对误差",
|
|
||||||
)
|
|
||||||
save_figure(fig, "figures/mc_convergence")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"""评价类题目的标准流程:无量纲化 → 赋权 → TOPSIS 排序 → 敏感性。
|
|
||||||
|
|
||||||
这是中文建模赛出现频率最高的题型之一。
|
|
||||||
关键点(也是最容易翻车的地方):**指标方向必须显式声明**。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from mcm_toolkit.decision import entropy_weights, topsis
|
|
||||||
from mcm_toolkit.solve import robustness_test
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
# 3 个城市 × 4 个指标
|
|
||||||
# 人均GDP(效益) 空气PM2.5(成本) 绿化率(效益) 通勤时间(成本)
|
|
||||||
X = np.array(
|
|
||||||
[
|
|
||||||
[120.0, 35.0, 42.0, 38.0],
|
|
||||||
[95.0, 55.0, 36.0, 29.0],
|
|
||||||
[140.0, 28.0, 45.0, 47.0],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
labels = ["城市A", "城市B", "城市C"]
|
|
||||||
benefit = [0, 2] # 只有第 0、2 列是"越大越好"
|
|
||||||
|
|
||||||
# 1) 客观赋权
|
|
||||||
w = entropy_weights(X, benefit_columns=benefit)
|
|
||||||
print("熵权:", np.round(w.params["weights"], 4))
|
|
||||||
|
|
||||||
# 2) TOPSIS 排序
|
|
||||||
result = topsis(X, weights=w.params["weights"], benefit_columns=benefit, labels=labels)
|
|
||||||
print(result.data)
|
|
||||||
|
|
||||||
# 3) 鲁棒性:权重扰动 ±10% 后排名还稳不稳?(论文里必须回答这个问题)
|
|
||||||
def model(**kw):
|
|
||||||
return topsis(X, weights=kw["weights"], benefit_columns=benefit).data["rank"].tolist()
|
|
||||||
|
|
||||||
rob = robustness_test(model, params={"weights": w.params["weights"]}, perturbation=0.1, n_samples=500, random_state=0)
|
|
||||||
print("排名稳定性:", rob.metrics)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# 例子
|
|
||||||
|
|
||||||
每个模块一个能直接跑的最小例子。
|
|
||||||
|
|
||||||
> **当前状态**:这些脚本是**用法规格**,函数实现填好之前跑不通
|
|
||||||
> (会抛 `NotImplementedError`)。实现顺序见 `docs/03-roadmap.md`。
|
|
||||||
>
|
|
||||||
> `00_quickstart.py` 与 `01_clean_and_describe.py` 用到的函数大多已实现,可以跑;
|
|
||||||
> 两者末尾各有 `TODO(L0)`,等 `stats` / `viz` 补齐。
|
|
||||||
|
|
||||||
| 文件 | 对应模块 | 覆盖的 L 档 |
|
|
||||||
|---|---|---|
|
|
||||||
| `00_quickstart.py` | 全流程 | L0 |
|
|
||||||
| `01_clean_and_describe.py` | `data` + `stats` | L0 |
|
|
||||||
| `02_regression_compare.py` | `models` | L1 |
|
|
||||||
| `03_monte_carlo.py` | `solve/simulate` | L0 |
|
|
||||||
| `04_decision_topsis.py` | `decision` | L1 |
|
|
||||||
| `05_sensitivity.py` | `solve/sensitivity` | L1 |
|
|
||||||
|
|
||||||
跑法:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\.venv\Scripts\python.exe examples\03_monte_carlo.py
|
|
||||||
```
|
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -1,25 +0,0 @@
|
|||||||
# 历年真题
|
|
||||||
|
|
||||||
每做一道题,就把"题目 → 思路 → 代码 → 结论"沉淀到这里,
|
|
||||||
并**反向补 1~3 个通用函数进 `src/mcm_toolkit/`**。
|
|
||||||
|
|
||||||
这是这个仓库能长期长大的唯一机制:做的是题,积累的是框架。
|
|
||||||
|
|
||||||
## 目录约定
|
|
||||||
|
|
||||||
```text
|
|
||||||
problems/
|
|
||||||
└── 2025_C/
|
|
||||||
├── README.md 题目摘要 + 思路 + 结论 + 复盘(**必写**)
|
|
||||||
├── data/ 处理后的数据(原始数据不入库,见 .gitignore)
|
|
||||||
├── src/ 这道题的专用代码(不通用、不抽进 toolkit 的部分)
|
|
||||||
├── figures/ 定稿图(png + pdf)
|
|
||||||
└── paper/ 论文片段 / LaTeX
|
|
||||||
```
|
|
||||||
|
|
||||||
## 复盘问题(每道题做完必答)
|
|
||||||
|
|
||||||
1. 这道题用到了 toolkit 里的哪些函数?哪些是**第一次用**?
|
|
||||||
2. 哪些代码是**第二次、第三次写**了?→ 该抽进 toolkit。
|
|
||||||
3. 哪个函数**不够用 / 用起来别扭**?→ 改接口。
|
|
||||||
4. 论文里被评审/队友质疑的地方是什么?→ 对应的函数要补文档或补鲁棒性检验。
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=68", "wheel"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "mcm-toolkit"
|
|
||||||
version = "0.0.1"
|
|
||||||
description = "MCM/ICM 竞赛层工具箱:高频算法自实现 + 成熟库薄封装 + 统一结果接口"
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.10"
|
|
||||||
license = { text = "MIT" }
|
|
||||||
authors = [{ name = "孤竹居士" }]
|
|
||||||
keywords = ["MCM", "ICM", "mathematical-modeling", "competition"]
|
|
||||||
classifiers = [
|
|
||||||
"Programming Language :: Python :: 3",
|
|
||||||
"License :: OSI Approved :: MIT License",
|
|
||||||
"Operating System :: OS Independent",
|
|
||||||
"Topic :: Scientific/Engineering :: Mathematics",
|
|
||||||
]
|
|
||||||
# 核心依赖只留 4 个:core / data / stats / viz 只需要这些。
|
|
||||||
# 其余全部走 optional-dependencies,保证"比赛现场装不上包也能跑一半"。
|
|
||||||
dependencies = [
|
|
||||||
"numpy>=1.24",
|
|
||||||
"scipy>=1.10",
|
|
||||||
"pandas>=2.0",
|
|
||||||
"matplotlib>=3.7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
ml = ["scikit-learn>=1.3", "statsmodels>=0.14"]
|
|
||||||
graph = ["networkx>=3.1"]
|
|
||||||
sensitivity = ["SALib>=1.4"]
|
|
||||||
symbolic = ["sympy>=1.12"]
|
|
||||||
moo = ["pymoo>=0.6"]
|
|
||||||
excel = ["openpyxl>=3.1"]
|
|
||||||
all = ["mcm-toolkit[ml,graph,sensitivity,symbolic,moo,excel]"]
|
|
||||||
dev = ["pytest>=7.4", "ruff>=0.4", "pyright>=1.1"]
|
|
||||||
|
|
||||||
[project.urls]
|
|
||||||
Homepage = "https://github.com/"
|
|
||||||
|
|
||||||
[tool.setuptools]
|
|
||||||
package-dir = { "" = "src" }
|
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
|
||||||
where = ["src"]
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
line-length = 100
|
|
||||||
target-version = "py310"
|
|
||||||
src = ["src", "tests"]
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["E", "F", "W", "I", "UP", "B", "SIM", "NPY", "RET", "PTH"]
|
|
||||||
ignore = ["E501"]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
testpaths = ["tests"]
|
|
||||||
addopts = "-q"
|
|
||||||
filterwarnings = ["error::RuntimeWarning"]
|
|
||||||
|
|
||||||
[tool.pyright]
|
|
||||||
include = ["src", "tests"]
|
|
||||||
typeCheckingMode = "basic"
|
|
||||||
# 不写 venvPath/venv 的话,pyright 会去 PATH 上找 python;本机 PATH 上的
|
|
||||||
# python 是 agent-tools 的 shim(Codex 运行时,Python 3.14,没有 numpy/pandas),
|
|
||||||
# 于是全部报 "无法解析导入 numpy/pandas"。指到工作区的 .venv 才稳定。
|
|
||||||
venvPath = "."
|
|
||||||
venv = ".venv"
|
|
||||||
# 与两套环境一致(venv 3.13.9 / conda 3.13.15);不写会跟着 PATH 上那个 3.14 走。
|
|
||||||
pythonVersion = "3.13"
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Environment self-check: interpreters, conda envs, dependencies, package import, tests.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Run this on day one of a contest so environment problems surface before you
|
|
||||||
start working, instead of three hours before the deadline.
|
|
||||||
|
|
||||||
Checks both environments:
|
|
||||||
* .venv lightweight, offline-safe (10 MB)
|
|
||||||
* conda env "mcm-toolkit" the main working environment
|
|
||||||
|
|
||||||
NOTE: this file is intentionally pure ASCII, UTF-8 without BOM, LF endings.
|
|
||||||
Windows PowerShell 5.1 parses .ps1 as ANSI when there is no BOM, so non-ASCII
|
|
||||||
characters (e.g. Chinese) turn into illegal bytes and cause syntax errors.
|
|
||||||
|
|
||||||
.PARAMETER SkipTests
|
|
||||||
Do not run pytest.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\scripts\doctor.ps1
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[switch]$SkipTests
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Continue'
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
||||||
|
|
||||||
# Python snippet kept as a single-quoted here-string: it must not contain double
|
|
||||||
# quotes, because PowerShell 5.1 strips them when passing arguments to native programs.
|
|
||||||
$probe = @'
|
|
||||||
import importlib
|
|
||||||
mods = ['numpy','scipy','pandas','matplotlib','sklearn','statsmodels',
|
|
||||||
'networkx','sympy','pytest','ruff','SALib','pymoo','pyright','openpyxl']
|
|
||||||
for name in mods:
|
|
||||||
try:
|
|
||||||
mod = importlib.import_module(name)
|
|
||||||
ver = getattr(mod, '__version__', '')
|
|
||||||
print(' OK {:12s} {}'.format(name, ver))
|
|
||||||
except Exception as exc:
|
|
||||||
print(' MISS {:12s} ({})'.format(name, type(exc).__name__))
|
|
||||||
'@
|
|
||||||
|
|
||||||
function Report-Interpreter {
|
|
||||||
param([string]$Label, [string]$Py)
|
|
||||||
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host "--- $Label ---" -ForegroundColor Cyan
|
|
||||||
if (-not (Test-Path -LiteralPath $Py)) {
|
|
||||||
Write-Host " not found: $Py" -ForegroundColor Yellow
|
|
||||||
return
|
|
||||||
}
|
|
||||||
& $Py -c "import sys; print(' exe :', sys.executable); print(' python:', sys.version.split()[0])"
|
|
||||||
& $Py -c $probe
|
|
||||||
& $Py -c "import mcm_toolkit as m; print(' mcm_toolkit', m.__version__, '/', len(m.__all__), 'public names')"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host '=== MCM/ICM Toolkit environment check ===' -ForegroundColor Cyan
|
|
||||||
Write-Host "repo: $repoRoot"
|
|
||||||
|
|
||||||
Report-Interpreter -Label 'venv (.venv) - lightweight / offline-safe' -Py (Join-Path $repoRoot '.venv\Scripts\python.exe')
|
|
||||||
|
|
||||||
$conda = Get-Command conda -ErrorAction SilentlyContinue
|
|
||||||
$condaExe = if ($conda) { $conda.Source } else { $null }
|
|
||||||
if (-not $condaExe) {
|
|
||||||
foreach ($c in @('D:\anaconda\Scripts\conda.exe', "$env:USERPROFILE\anaconda3\Scripts\conda.exe")) {
|
|
||||||
if (Test-Path -LiteralPath $c) { $condaExe = $c; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($condaExe) {
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host "--- conda ($condaExe) ---" -ForegroundColor Cyan
|
|
||||||
& $condaExe --version | ForEach-Object { Write-Host " $_" }
|
|
||||||
Write-Host ' environments:'
|
|
||||||
& $condaExe env list | ForEach-Object { Write-Host " $_" }
|
|
||||||
|
|
||||||
$condaPy = Join-Path (Split-Path -Parent (Split-Path -Parent $condaExe)) 'envs\mcm-toolkit\python.exe'
|
|
||||||
Report-Interpreter -Label 'conda env "mcm-toolkit" - main working env' -Py $condaPy
|
|
||||||
} else {
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host '--- conda ---' -ForegroundColor Cyan
|
|
||||||
Write-Host ' conda not found on PATH or in the usual locations' -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $SkipTests) {
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host '--- tests ---' -ForegroundColor Cyan
|
|
||||||
Push-Location $repoRoot
|
|
||||||
foreach ($py in @((Join-Path $repoRoot '.venv\Scripts\python.exe'), $condaPy)) {
|
|
||||||
if ($py -and (Test-Path -LiteralPath $py)) {
|
|
||||||
Write-Host " $py"
|
|
||||||
& $py -m pytest -p no:cacheprovider -q 2>&1 | Select-Object -Last 1 | ForEach-Object { Write-Host " $_" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Pop-Location
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host 'Check finished.' -ForegroundColor Cyan
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Create the conda environment "mcm-toolkit".
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Three ways to build the environment, in order of preference:
|
|
||||||
|
|
||||||
(default) explicit package list, installed from the "defaults" channel.
|
|
||||||
Needs internet. Measured on this machine: ~14.5 min, ~3 GB download.
|
|
||||||
-FromYml same, but driven by environment.yml (better for reproducing later).
|
|
||||||
-CloneBase mirrors the whole base environment. NOTE: on conda 25.11 this is NOT
|
|
||||||
a fast local copy -- conda replays base's full package list and
|
|
||||||
downloads/installs it. Use it only when you want an exact copy of base.
|
|
||||||
|
|
||||||
To work offline later, build the environment BEFORE the contest and keep the
|
|
||||||
conda package cache (D:\anaconda\pkgs) intact.
|
|
||||||
|
|
||||||
NOTE: this file is intentionally pure ASCII, UTF-8 without BOM, LF endings.
|
|
||||||
Windows PowerShell 5.1 parses .ps1 as ANSI when there is no BOM, so non-ASCII
|
|
||||||
characters (e.g. Chinese) turn into illegal bytes and cause syntax errors.
|
|
||||||
|
|
||||||
.PARAMETER Name
|
|
||||||
conda environment name. Default: mcm-toolkit.
|
|
||||||
|
|
||||||
.PARAMETER FromYml
|
|
||||||
Build from environment.yml instead of the inline package list.
|
|
||||||
|
|
||||||
.PARAMETER CloneBase
|
|
||||||
Clone the base environment instead of installing a explicit package list.
|
|
||||||
|
|
||||||
.PARAMETER Force
|
|
||||||
Remove and recreate the environment if it already exists.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\scripts\setup-conda.ps1
|
|
||||||
Create "mcm-toolkit" with the science stack (needs internet).
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\scripts\setup-conda.ps1 -FromYml -Force
|
|
||||||
Rebuild from environment.yml.
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[string]$Name = 'mcm-toolkit',
|
|
||||||
[switch]$FromYml,
|
|
||||||
[switch]$CloneBase,
|
|
||||||
[switch]$Force
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
||||||
|
|
||||||
# Packages required by pyproject.toml's core deps plus the [ml] / [graph] /
|
|
||||||
# [symbolic] / [excel] / [dev] extras. SALib, pymoo and pyright are pip-only
|
|
||||||
# (see install after the conda step).
|
|
||||||
$packages = @(
|
|
||||||
'python=3.13',
|
|
||||||
'numpy', 'scipy', 'pandas', 'matplotlib',
|
|
||||||
'scikit-learn', 'statsmodels',
|
|
||||||
'networkx', 'sympy', 'openpyxl',
|
|
||||||
'pytest', 'ruff',
|
|
||||||
'pip'
|
|
||||||
)
|
|
||||||
$pipOnly = @('SALib', 'pymoo', 'pyright')
|
|
||||||
|
|
||||||
function Find-Conda {
|
|
||||||
$candidates = @(
|
|
||||||
"$env:USERPROFILE\anaconda3\Scripts\conda.exe",
|
|
||||||
"$env:USERPROFILE\miniconda3\Scripts\conda.exe",
|
|
||||||
'D:\anaconda\Scripts\conda.exe',
|
|
||||||
'C:\ProgramData\Anaconda3\Scripts\conda.exe',
|
|
||||||
'C:\ProgramData\miniconda3\Scripts\conda.exe'
|
|
||||||
)
|
|
||||||
foreach ($c in $candidates) { if (Test-Path -LiteralPath $c) { return $c } }
|
|
||||||
$onPath = Get-Command conda -ErrorAction SilentlyContinue
|
|
||||||
if ($onPath) { return $onPath.Source }
|
|
||||||
throw 'conda not found. Install Anaconda/Miniconda first.'
|
|
||||||
}
|
|
||||||
|
|
||||||
$conda = Find-Conda
|
|
||||||
Write-Host "[1/5] conda = $conda"
|
|
||||||
|
|
||||||
# PITFALL: the Anaconda 25.x ToS plugin fetches channel metadata from
|
|
||||||
# repo.anaconda.com before every create/install and crashes when offline.
|
|
||||||
# This env var auto-accepts the ToS: it skips the network call while keeping
|
|
||||||
# the libmamba solver (unlike --no-plugins, which disables all plugins).
|
|
||||||
$env:CONDA_PLUGINS_AUTO_ACCEPT_TOS = 'yes'
|
|
||||||
$env:CONDA_AUTO_UPDATE_CONDA = 'false'
|
|
||||||
|
|
||||||
# PITFALL: envs_dirs and pkgs_dirs must be writable, otherwise conda fails with
|
|
||||||
# NotWritableError or "Error encountered while attempting to create cache directory".
|
|
||||||
Write-Host '[2/5] current envs_dirs / pkgs_dirs:'
|
|
||||||
& $conda config --show envs_dirs pkgs_dirs 2>$null | ForEach-Object { Write-Host " $_" }
|
|
||||||
|
|
||||||
$existing = (& $conda env list --json | ConvertFrom-Json).envs |
|
|
||||||
Where-Object { (Split-Path -Leaf $_) -eq $Name }
|
|
||||||
if ($existing) {
|
|
||||||
if (-not $Force) {
|
|
||||||
Write-Host "Environment '$Name' already exists ($existing). Use -Force to rebuild." -ForegroundColor Yellow
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
Write-Host "Environment '$Name' exists, -Force given, removing first ..."
|
|
||||||
& $conda env remove -n $Name -y
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host '[3/5] creating the environment (this can take 10-20 minutes) ...'
|
|
||||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
|
||||||
if ($FromYml) {
|
|
||||||
$yml = Join-Path $repoRoot 'environment.yml'
|
|
||||||
if (-not (Test-Path -LiteralPath $yml)) { throw "Not found: $yml" }
|
|
||||||
& $conda env create -n $Name -f $yml
|
|
||||||
} elseif ($CloneBase) {
|
|
||||||
& $conda create -n $Name --clone base -y
|
|
||||||
} else {
|
|
||||||
& $conda create -n $Name -y @packages
|
|
||||||
}
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "conda create failed (exit code $LASTEXITCODE)" }
|
|
||||||
Write-Host (" done in {0:N0}s" -f $sw.Elapsed.TotalSeconds)
|
|
||||||
|
|
||||||
$envPy = Join-Path (Split-Path -Parent (Split-Path -Parent $conda)) "envs\$Name\python.exe"
|
|
||||||
if (-not (Test-Path -LiteralPath $envPy)) {
|
|
||||||
$envsRoot = (& $conda config --show envs_dirs | Select-String -Pattern '\S' | Select-Object -First 1).ToString().Trim()
|
|
||||||
$envPy = Join-Path $envsRoot "$Name\python.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host '[4/5] installing pip-only extras (SALib, pymoo, pyright) + this project ...'
|
|
||||||
& $envPy -m pip install --disable-pip-version-check --no-warn-script-location -q @pipOnly
|
|
||||||
& $envPy -m pip install -e $repoRoot --no-deps --no-build-isolation --disable-pip-version-check -q
|
|
||||||
|
|
||||||
Write-Host '[5/5] verifying ...'
|
|
||||||
& $envPy -c "import numpy, scipy, pandas, matplotlib, sklearn, statsmodels, networkx, sympy, SALib, pymoo, mcm_toolkit; print(' numpy', numpy.__version__, '| pandas', pandas.__version__, '| sklearn', sklearn.__version__); print(' mcm_toolkit', mcm_toolkit.__version__)"
|
|
||||||
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host 'Done. Activate with:' -ForegroundColor Green
|
|
||||||
Write-Host " conda activate $Name"
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Create .venv inside the repo and install mcm-toolkit in editable mode.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Uses --system-site-packages so the venv reuses the scientific stack already
|
|
||||||
installed in Anaconda base. That makes the whole step OFFLINE: no package is
|
|
||||||
downloaded, yet numpy / scipy / pandas / matplotlib / sklearn / statsmodels /
|
|
||||||
networkx / sympy are all importable.
|
|
||||||
|
|
||||||
The editable install uses --no-deps --no-build-isolation: dependencies are
|
|
||||||
already provided by base, and pip must not go online for a build backend.
|
|
||||||
|
|
||||||
NOTE: this file is intentionally pure ASCII and UTF-8 without BOM.
|
|
||||||
Windows PowerShell 5.1 parses .ps1 as ANSI when there is no BOM, so non-ASCII
|
|
||||||
characters (e.g. Chinese) turn into illegal bytes and cause syntax errors.
|
|
||||||
|
|
||||||
.PARAMETER Python
|
|
||||||
Interpreter used to create the venv. Default: D:\anaconda\python.exe
|
|
||||||
|
|
||||||
.PARAMETER Recreate
|
|
||||||
Delete and recreate .venv if it already exists.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\scripts\setup-venv.ps1
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[string]$Python = 'D:\anaconda\python.exe',
|
|
||||||
[switch]$Recreate
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
||||||
$venvDir = Join-Path $repoRoot '.venv'
|
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath $Python)) {
|
|
||||||
$cmd = Get-Command python -ErrorAction SilentlyContinue
|
|
||||||
if (-not $cmd) { throw "Python interpreter not found: $Python" }
|
|
||||||
$Python = $cmd.Source
|
|
||||||
Write-Host "Using python from PATH: $Python" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Test-Path -LiteralPath $venvDir) {
|
|
||||||
if (-not $Recreate) {
|
|
||||||
Write-Host '.venv already exists. Use -Recreate to rebuild.' -ForegroundColor Yellow
|
|
||||||
} else {
|
|
||||||
Write-Host 'Removing existing .venv ...'
|
|
||||||
Remove-Item -LiteralPath $venvDir -Recurse -Force
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath $venvDir)) {
|
|
||||||
Write-Host '[1/4] creating .venv (--system-site-packages, reuses Anaconda base) ...'
|
|
||||||
& $Python -m venv --system-site-packages --prompt 'mcm-toolkit' $venvDir
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'venv creation failed' }
|
|
||||||
}
|
|
||||||
|
|
||||||
$venvPy = Join-Path $venvDir 'Scripts\python.exe'
|
|
||||||
$venvSitePackages = Join-Path $venvDir 'Lib\site-packages'
|
|
||||||
|
|
||||||
Write-Host '[2/4] linking base site-packages for static analyzers (.pth) ...'
|
|
||||||
# pyright / Pylance do NOT honour "include-system-site-packages" in pyvenv.cfg,
|
|
||||||
# but they DO read *.pth files found in site-packages. Without this file they see
|
|
||||||
# only .venv\Lib\site-packages (which holds just pip), so every "import numpy"
|
|
||||||
# in the editor is reported as unresolved even though the interpreter is right.
|
|
||||||
$baseSitePackages = (& $Python -c "import sysconfig; print(sysconfig.get_paths()['purelib'])").Trim()
|
|
||||||
if (-not (Test-Path -LiteralPath $baseSitePackages)) { throw "base site-packages not found: $baseSitePackages" }
|
|
||||||
Set-Content -LiteralPath (Join-Path $venvSitePackages '_mcm_base_site_packages.pth') -Value $baseSitePackages -Encoding ASCII
|
|
||||||
Write-Host " -> $baseSitePackages"
|
|
||||||
|
|
||||||
Write-Host '[3/4] editable install of mcm-toolkit (--no-deps --no-build-isolation) ...'
|
|
||||||
& $venvPy -m pip install -e $repoRoot --no-deps --no-build-isolation --disable-pip-version-check
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'pip install -e failed' }
|
|
||||||
|
|
||||||
Write-Host '[4/4] verifying ...'
|
|
||||||
& $venvPy -c "import mcm_toolkit, numpy, scipy, pandas, matplotlib; print('mcm_toolkit', mcm_toolkit.__version__); print('numpy', numpy.__version__)"
|
|
||||||
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host 'Done. Activate with:' -ForegroundColor Green
|
|
||||||
Write-Host ' .\.venv\Scripts\Activate.ps1'
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""mcm_toolkit —— MCM/ICM 竞赛层工具箱。
|
|
||||||
|
|
||||||
设计原则见 docs/02-architecture.md:
|
|
||||||
|
|
||||||
* 薄封装优先:能一行转发 SciPy / sklearn / statsmodels / NetworkX / SALib 的,就不自己写。
|
|
||||||
* 自己实现是有目的的:只写"库里没有、或自己写才学得会"的算法。
|
|
||||||
* 统一出口:拟合 / 求解 / 模拟 / 评价类函数一律返回 :class:`Result`。
|
|
||||||
* 可复现:含随机的函数都接受 `random_state`。
|
|
||||||
|
|
||||||
顶层只导出子包与 6 个高频快捷函数,不做 `import *`,避免污染命名空间。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
# 子包:按 `import mcm_toolkit as mcm` 之后 `mcm.stats.xxx` 使用
|
|
||||||
from . import (
|
|
||||||
core, # noqa: F401
|
|
||||||
data, # noqa: F401
|
|
||||||
decision, # noqa: F401
|
|
||||||
models, # noqa: F401
|
|
||||||
solve, # noqa: F401
|
|
||||||
stats, # noqa: F401
|
|
||||||
viz, # noqa: F401
|
|
||||||
)
|
|
||||||
from ._version import __version__
|
|
||||||
|
|
||||||
# 高频快捷函数:比赛时最常用的几个,允许 `mcm.monte_carlo(...)` 直接用
|
|
||||||
from .core import Result, get_rng, set_seed # noqa: F401
|
|
||||||
from .decision import topsis # noqa: F401
|
|
||||||
from .models import compare_models # noqa: F401
|
|
||||||
from .solve import monte_carlo, parameter_sweep # noqa: F401
|
|
||||||
from .stats import bootstrap_ci # noqa: F401
|
|
||||||
|
|
||||||
# 公开 API 清单(不是"导出按钮",只列对外承诺稳定的名字)
|
|
||||||
__all__ = [
|
|
||||||
"__version__",
|
|
||||||
# 子包
|
|
||||||
"core",
|
|
||||||
"data",
|
|
||||||
"stats",
|
|
||||||
"models",
|
|
||||||
"solve",
|
|
||||||
"decision",
|
|
||||||
"viz",
|
|
||||||
# 快捷函数
|
|
||||||
"Result",
|
|
||||||
"get_rng",
|
|
||||||
"set_seed",
|
|
||||||
"bootstrap_ci",
|
|
||||||
"compare_models",
|
|
||||||
"monte_carlo",
|
|
||||||
"parameter_sweep",
|
|
||||||
"topsis",
|
|
||||||
]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
"""版本号唯一来源。"""
|
|
||||||
|
|
||||||
__version__ = "0.0.1"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""core —— 基础设施:结果对象、随机数、参数校验与入口转换。
|
|
||||||
|
|
||||||
其他所有包都依赖本包;本包**不**依赖任何其他子包。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .random import get_rng, set_seed
|
|
||||||
from .result import Result
|
|
||||||
from .validate import (
|
|
||||||
as_array,
|
|
||||||
check_array,
|
|
||||||
check_probability,
|
|
||||||
check_same_length,
|
|
||||||
check_square_matrix,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Result",
|
|
||||||
"as_array",
|
|
||||||
"get_rng",
|
|
||||||
"set_seed",
|
|
||||||
"check_array",
|
|
||||||
"check_probability",
|
|
||||||
"check_same_length",
|
|
||||||
"check_square_matrix",
|
|
||||||
]
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""随机数约定。
|
|
||||||
|
|
||||||
规则(见 docs/02-architecture.md §4.2):
|
|
||||||
|
|
||||||
* 所有含随机的公开函数签名都带 `random_state=None`,内部第一行取 rng。
|
|
||||||
* 禁止使用 `np.random.*` 旧式全局接口,也禁止裸用标准库 `random`。
|
|
||||||
(唯一例外是 :func:`set_seed` 里的 `np.random.seed`,见该函数说明。)
|
|
||||||
* 论文附录必须写明 seed —— 提交要能复现。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
__all__ = ["get_rng", "set_seed"]
|
|
||||||
|
|
||||||
# 模块级共享生成器:set_seed 换掉它,无参 get_rng 复用它。
|
|
||||||
_GLOBAL_RNG: np.random.Generator | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_rng(
|
|
||||||
random_state: int | np.integer | np.random.Generator | None = None,
|
|
||||||
) -> np.random.Generator:
|
|
||||||
"""取一个随机数生成器。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
random_state:
|
|
||||||
`int` / `np.integer` 时返回固定种子的独立生成器(可复现);
|
|
||||||
`None` 时返回共享的全局生成器(由 :func:`set_seed` 设定,**会被推进**);
|
|
||||||
已经是 :class:`np.random.Generator` 时原样返回(便于函数间串联)。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
传进来的既不是整数、也不是 Generator、也不是 None。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
无参调用返回的是**同一个**生成器对象,所以连续两次 `get_rng()` 取到的随机数
|
|
||||||
不同(流在推进)。这既保证"重跑一遍结果一样",又避免两个都省略 `random_state`
|
|
||||||
的函数抽到完全相同的数。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> rng = get_rng(42)
|
|
||||||
>>> bool(rng.random() == get_rng(42).random())
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
if isinstance(random_state, np.random.Generator):
|
|
||||||
return 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 | np.integer) -> None:
|
|
||||||
"""设置全局随机种子。
|
|
||||||
|
|
||||||
除了换掉本包的共享生成器,还会一并设置 numpy 的**旧全局状态**:
|
|
||||||
sklearn / statsmodels 内部走 `check_random_state(None)` 时用的就是它,
|
|
||||||
只管自己的生成器的话,这些库照样不可复现。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> set_seed(42)
|
|
||||||
"""
|
|
||||||
global _GLOBAL_RNG
|
|
||||||
_GLOBAL_RNG = np.random.default_rng(seed)
|
|
||||||
# 全项目唯一允许碰旧接口的地方:这里是"设种子",不是"取随机数"。
|
|
||||||
np.random.seed(seed) # noqa: NPY002
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
"""统一结果对象。
|
|
||||||
|
|
||||||
第一版只有**一个** :class:`Result`(原建议里的 5 个 Result 类属于过早分类)。
|
|
||||||
任何"拟合 / 求解 / 模拟 / 评价"函数都只返回它,不返回裸 tuple。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
# 单个值在摘要里的最大字符数,超了就截断。
|
|
||||||
_MAX_TEXT = 60
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Result:
|
|
||||||
"""所有计算型函数的统一返回值。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
name:
|
|
||||||
产生该结果的方法名,例如 `"monte_carlo"`。
|
|
||||||
params:
|
|
||||||
参数估计 / 决策变量取值 / 权重等"输入侧"的结论。
|
|
||||||
metrics:
|
|
||||||
评估指标,例如 `{"rmse": 0.31, "r2": 0.94}`。
|
|
||||||
data:
|
|
||||||
主要载荷:预测值、模拟轨迹、排序结果、收益数组等。
|
|
||||||
success:
|
|
||||||
是否正常完成。失败时仍返回 Result,但 `success=False` 且 `message` 说明原因。
|
|
||||||
message:
|
|
||||||
给人看的一句话说明,可直接抄进论文的"方法说明"。
|
|
||||||
|
|
||||||
两个出口::meth:`summary` 给论文用(Markdown 表格),`repr` 给调试用(紧凑单行)。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = Result("demo", params={"alpha": 0.5}, metrics={"r2": 0.9})
|
|
||||||
>>> r.success
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
params: dict[str, Any] | None = None
|
|
||||||
metrics: dict[str, Any] | None = None
|
|
||||||
data: Any = None
|
|
||||||
success: bool = True
|
|
||||||
message: str = ""
|
|
||||||
extras: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
"""紧凑的单行摘要,方便在 notebook / 调试器里一眼确认结果。
|
|
||||||
|
|
||||||
`data` 只给类型和规模,**绝不**整个打印——模拟轨迹动辄十万个数,
|
|
||||||
repr 一旦刷屏,traceback 和调试器就都没法看了。
|
|
||||||
`__repr__` 还必须永不抛异常,否则报错信息本身会变成报错源。
|
|
||||||
"""
|
|
||||||
parts = [f"success={self.success}"]
|
|
||||||
if self.params:
|
|
||||||
parts.append(f"params={_fmt_mapping(self.params)}")
|
|
||||||
if self.metrics:
|
|
||||||
parts.append(f"metrics={_fmt_mapping(self.metrics)}")
|
|
||||||
if self.data is not None:
|
|
||||||
parts.append(f"data={_describe_data(self.data)}")
|
|
||||||
if self.message:
|
|
||||||
parts.append(f"message={self.message!r}")
|
|
||||||
return f"Result({self.name}, {', '.join(parts)})"
|
|
||||||
|
|
||||||
def summary(self) -> str:
|
|
||||||
"""返回可直接贴进论文的 Markdown 表格。
|
|
||||||
|
|
||||||
行顺序为 `success` → `params.*` → `metrics.*` → `extras.*`,
|
|
||||||
按字典插入顺序输出(比赛时按自己想要的阅读顺序建字典即可)。
|
|
||||||
`message` 非空时以引用行附在表后。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> print(Result("demo", metrics={"r2": 0.9}).summary())
|
|
||||||
### demo
|
|
||||||
|
|
||||||
| 项 | 值 |
|
|
||||||
|---|---|
|
|
||||||
| success | True |
|
|
||||||
| metrics.r2 | 0.9 |
|
|
||||||
"""
|
|
||||||
rows: list[tuple[str, Any]] = [("success", self.success)]
|
|
||||||
for prefix, mapping in (
|
|
||||||
("params", self.params),
|
|
||||||
("metrics", self.metrics),
|
|
||||||
("extras", self.extras),
|
|
||||||
):
|
|
||||||
for key, value in (mapping or {}).items():
|
|
||||||
rows.append((f"{prefix}.{key}", value))
|
|
||||||
|
|
||||||
lines = [f"### {self.name}", "", "| 项 | 值 |", "|---|---|"]
|
|
||||||
lines += [f"| {key} | {_fmt_cell(value)} |" for key, value in rows]
|
|
||||||
if self.message:
|
|
||||||
lines += ["", f"> {self.message}"]
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_scalar(value: Any) -> str:
|
|
||||||
"""把一个值格式化成短、稳、论文友好的字符串。
|
|
||||||
|
|
||||||
`bool` 必须在 `int` 之前判断——`True` 同时是 `int` 的实例,
|
|
||||||
顺序反了就会把 `True` 打印成 `1`。
|
|
||||||
"""
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return str(value)
|
|
||||||
if isinstance(value, float):
|
|
||||||
# 6 位有效数字:够论文用,又不会出现 0.30000000000000004。
|
|
||||||
return f"{value:.6g}"
|
|
||||||
if isinstance(value, int):
|
|
||||||
return str(value)
|
|
||||||
# 其他类型先归一化空白(换行会把单行 repr 和 Markdown 表格都撑破),再截断。
|
|
||||||
return _truncate(" ".join(str(value).split()))
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_cell(value: Any) -> str:
|
|
||||||
"""Markdown 表格单元格:值里出现竖线会把表格顶散,必须转义。"""
|
|
||||||
return _fmt_scalar(value).replace("|", r"\|")
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_mapping(mapping: Mapping[str, Any]) -> str:
|
|
||||||
return "{" + ", ".join(f"{key}: {_fmt_scalar(value)}" for key, value in mapping.items()) + "}"
|
|
||||||
|
|
||||||
|
|
||||||
def _describe_data(data: Any) -> str:
|
|
||||||
"""只描述 `data` 的类型与规模,不打印内容。"""
|
|
||||||
shape = getattr(data, "shape", None)
|
|
||||||
if shape is not None: # numpy 数组 / pandas DataFrame、Series
|
|
||||||
return f"{type(data).__name__}({', '.join(str(d) for d in shape)})"
|
|
||||||
if isinstance(data, str | bytes | list | tuple | set | frozenset | dict):
|
|
||||||
return f"{type(data).__name__}({len(data)})"
|
|
||||||
return type(data).__name__
|
|
||||||
|
|
||||||
|
|
||||||
def _truncate(text: str, limit: int = _MAX_TEXT) -> str:
|
|
||||||
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
"""参数校验与入口转换。
|
|
||||||
|
|
||||||
目的不是"教用户用库",而是**把错误提前到调用点**:
|
|
||||||
比赛时最贵的是 debug 时间,一个 `ValueError: 数组长度不一致` 比
|
|
||||||
十层 traceback 里的 `IndexError` 值钱得多。
|
|
||||||
|
|
||||||
**所有收数值的函数都从 `as_array` 进**:它一次做完"类型白名单 + 转 ndarray + 告诉调用方原来是什么",
|
|
||||||
省掉每个函数各抄一遍 `isinstance(x, pd.DataFrame)` / `select_dtypes` / `np.asarray`。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Literal, cast
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"as_array",
|
|
||||||
"check_array",
|
|
||||||
"check_probability",
|
|
||||||
"check_same_length",
|
|
||||||
"check_square_matrix",
|
|
||||||
]
|
|
||||||
|
|
||||||
ArrayKind = Literal["frame", "series", "array"]
|
|
||||||
|
|
||||||
_INPUT_TYPES = (pd.DataFrame, pd.Series, np.ndarray, list, tuple, int, float)
|
|
||||||
|
|
||||||
|
|
||||||
def as_array(
|
|
||||||
x: Any,
|
|
||||||
*,
|
|
||||||
numeric_only: bool = True,
|
|
||||||
dtype: Any = float,
|
|
||||||
ndim: int | None = None,
|
|
||||||
) -> tuple[np.ndarray, ArrayKind]:
|
|
||||||
"""统一入口:把入参收成 `ndarray`,并告诉调用方"原来是什么"。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
x:
|
|
||||||
`pandas.DataFrame` / `pandas.Series` / `numpy.ndarray` / `list` / `tuple`。
|
|
||||||
其余类型一律 `TypeError` —— 注意 `str` **也是可迭代对象**,白名单必须显式挡住,
|
|
||||||
否则 `"abc"` 会被当成三个字符悄悄算下去。
|
|
||||||
numeric_only:
|
|
||||||
默认 `True`:`DataFrame` **只取数值列**,非数值列自动跳过(一列都没有则 `ValueError`)。
|
|
||||||
走"元素级、输入输出同形"的函数(`normalize` / `standardize` / `log_transform`)
|
|
||||||
必须写 `numeric_only=False`:跳过列会让返回的表**少列**,与输入对不上;
|
|
||||||
输出是"一张数值结果表"的函数(`zscore` / `confidence_interval` / `t_test`)用默认值。
|
|
||||||
对 `Series` / 数组 / 列表无意义,自动忽略。
|
|
||||||
dtype:
|
|
||||||
目标 dtype,默认 `float`;传 `None` 表示保持原 dtype。
|
|
||||||
ndim:
|
|
||||||
期望维度,默认 `None` 表示**保持原样、绝不摊平**。
|
|
||||||
`1` 会**摊平**成一维("一串样本"的函数如 `t_test` / `confidence_interval` 要显式写);
|
|
||||||
`2` 只**校验**不重塑 —— 一维数据到底是"1 个样本"还是"1 个特征"没有唯一答案,不能替你猜。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
(numpy.ndarray, ArrayKind)
|
|
||||||
第二个元素是 `"frame"` / `"series"` / `"array"`,表示 `x` 原来是哪一类。
|
|
||||||
还原**故意不代做**,调用方自己手写(同一个函数不同形,没法一刀切):
|
|
||||||
|
|
||||||
```python
|
|
||||||
arr, kind = as_array(x, numeric_only=False)
|
|
||||||
... # 只写算法
|
|
||||||
if kind == "frame":
|
|
||||||
return pd.DataFrame(result, index=x.index, columns=x.columns)
|
|
||||||
if kind == "series":
|
|
||||||
return pd.Series(result, index=x.index, name=x.name)
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
`numeric_only=True` 时列名要用**同一份口径**:
|
|
||||||
`x.select_dtypes(include=[np.number]).columns`。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`x` 不在白名单里,或内容转不成 `dtype`。
|
|
||||||
ValueError
|
|
||||||
`numeric_only=True` 但 `DataFrame` 里一列数值都没有;`ndim` 不是 `None` / `1` / `2`;
|
|
||||||
`ndim=2` 但 `x` 不是二维。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
**本函数不删缺失值。** `DataFrame.corr` 是成对删、`describe` 是整列统计、
|
|
||||||
`paired=True` 的 t 检验必须**成对**删 —— 一旦在这里写 `arr[~isnan(arr)]`,
|
|
||||||
行与行的对应关系就永久丢了。删缺失是**语义**,交给各函数自己决定。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> arr, kind = as_array(pd.DataFrame({"a": [1, 2], "city": ["X", "Y"]}))
|
|
||||||
>>> arr.shape, kind
|
|
||||||
((2, 1), 'frame')
|
|
||||||
"""
|
|
||||||
if isinstance(x, pd.DataFrame):
|
|
||||||
kind: ArrayKind = "frame"
|
|
||||||
source: Any = x
|
|
||||||
if numeric_only:
|
|
||||||
numeric_cols = x.select_dtypes(include=[np.number]).columns
|
|
||||||
if len(numeric_cols) == 0:
|
|
||||||
raise ValueError(f"x 里一列数值都没有,只有 {list(x.columns)}")
|
|
||||||
# x[Index] 在 pandas 存根里是 DataFrame | Series 重载,按运行时真值收窄(坑 8)
|
|
||||||
source = cast(pd.DataFrame, x[numeric_cols])
|
|
||||||
elif isinstance(x, pd.Series):
|
|
||||||
kind = "series"
|
|
||||||
source = x
|
|
||||||
elif isinstance(x, _INPUT_TYPES):
|
|
||||||
kind = "array"
|
|
||||||
source = x
|
|
||||||
else:
|
|
||||||
raise TypeError(
|
|
||||||
f"x 必须是 DataFrame / Series / ndarray / list / tuple,你传入了 {type(x).__name__}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
arr = np.asarray(source, dtype=dtype)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
raise TypeError(f"x 的内容转不成 {dtype} 数组:{e}") from None
|
|
||||||
|
|
||||||
if ndim is not None:
|
|
||||||
if ndim not in (1, 2):
|
|
||||||
raise ValueError(f"ndim 只能是 None / 1 / 2,你传入了 {ndim}")
|
|
||||||
if ndim == 1:
|
|
||||||
arr = arr.ravel()
|
|
||||||
elif arr.ndim != 2:
|
|
||||||
raise ValueError(f"ndim=2 要求二维输入,但 x 是 {arr.ndim} 维")
|
|
||||||
|
|
||||||
return arr, kind
|
|
||||||
|
|
||||||
|
|
||||||
def check_array(x: Any, ndim: int | None = None, finite: bool = True) -> np.ndarray:
|
|
||||||
"""转成 ndarray 并做基本检查(维度、有限值)。
|
|
||||||
|
|
||||||
实现时应当复用 `as_array`(丢掉第二个返回值即可),不要重写一遍转换逻辑。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> check_array([1, 2, 3]).shape
|
|
||||||
(3,)
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def check_same_length(*arrays: Any) -> None:
|
|
||||||
"""检查多个数组长度一致,不一致时抛出带长度明细的 ValueError。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> check_same_length([1, 2], [3, 4])
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def check_probability(p: float) -> float:
|
|
||||||
"""检查 p 落在 [0, 1],返回 float。"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def check_square_matrix(a: Any) -> np.ndarray:
|
|
||||||
"""检查是方阵,返回 ndarray。AHP 判断矩阵、转移矩阵都要用。"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"""data —— 数据进出与清洗(最高频的一层)。
|
|
||||||
|
|
||||||
真实赛题数据往往缺列、单位不统一、日期格式混乱。
|
|
||||||
本包的目标是让"拿到数据 → 能建模"这一步不超过 10 分钟。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .clean import clean_missing, detect_outliers, impute_missing, winsorize
|
|
||||||
from .io import load_table, save_table
|
|
||||||
from .sample import bootstrap_sample, kfold_split, train_test_split
|
|
||||||
from .transform import log_transform, normalize, one_hot_encode, standardize
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"load_table",
|
|
||||||
"save_table",
|
|
||||||
"clean_missing",
|
|
||||||
"impute_missing",
|
|
||||||
"detect_outliers",
|
|
||||||
"winsorize",
|
|
||||||
"normalize",
|
|
||||||
"standardize",
|
|
||||||
"log_transform",
|
|
||||||
"one_hot_encode",
|
|
||||||
"train_test_split",
|
|
||||||
"kfold_split",
|
|
||||||
"bootstrap_sample",
|
|
||||||
]
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
"""缺失值、离群值、缩尾。
|
|
||||||
|
|
||||||
`detect_outliers` 属于 S 档:不只是"算出来",还要能解释给队友听
|
|
||||||
为什么要踢掉这几个点(论文里必须交代),所以配完整函数卡片
|
|
||||||
(docs/dev/function-card-template.md)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
from typing import Any, Literal, cast
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
__all__ = ["clean_missing", "detect_outliers", "impute_missing", "winsorize"]
|
|
||||||
|
|
||||||
|
|
||||||
def clean_missing(data: Any, axis: int = 0, threshold: float | None = None) -> Any:
|
|
||||||
"""删除缺失过多的行/列。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
data:
|
|
||||||
要清洗的表,必须是 `pandas.DataFrame`。
|
|
||||||
axis:
|
|
||||||
`0` = 按行删,`1` = 按列删。
|
|
||||||
threshold:
|
|
||||||
缺失比例**超过**该值就删,取值 0–1;`None` 表示只要有缺失就删。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
pandas.DataFrame
|
|
||||||
清洗后的新表(不改原表)。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`data` 不是 `pandas.DataFrame`。
|
|
||||||
ValueError
|
|
||||||
`axis` 既不是 0 也不是 1。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> clean_missing(df, threshold=0.5) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
if axis == 0:
|
|
||||||
target_axis = 'index'
|
|
||||||
elif axis == 1:
|
|
||||||
target_axis = 'columns'
|
|
||||||
else:
|
|
||||||
raise ValueError(f"axis参数只能是 0 或 1,你传入了{axis}")
|
|
||||||
|
|
||||||
if not isinstance(data,pd.DataFrame):
|
|
||||||
raise TypeError(f"data必须是pandas DataFrame,你传入了{type(data)}")
|
|
||||||
|
|
||||||
if threshold is not None and not 0 <= threshold <= 1:
|
|
||||||
raise ValueError(f"threshold必须在0和1之间,你传入了{threshold}")
|
|
||||||
|
|
||||||
if threshold is None:
|
|
||||||
return data.dropna(axis=target_axis,how='any')
|
|
||||||
|
|
||||||
total_len = data.shape[1 -axis]
|
|
||||||
min_non_na = math.ceil(total_len * (1 - threshold))
|
|
||||||
|
|
||||||
return data.dropna(axis=target_axis,thresh=min_non_na)
|
|
||||||
|
|
||||||
|
|
||||||
def impute_missing(data: Any, method: str = "mean", **kwargs: Any) -> Any:
|
|
||||||
"""缺失值填补。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
data:
|
|
||||||
要填补的表,必须是 `pandas.DataFrame`。
|
|
||||||
method:
|
|
||||||
`"mean"` / `"median"` / `"mode"` / `"ffill"` / `"interpolate"`。
|
|
||||||
**kwargs:
|
|
||||||
原样转交给底层的 `fillna` / `ffill` / `interpolate`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
pandas.DataFrame
|
|
||||||
填补后的表。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`data` 不是 `pandas.DataFrame`。
|
|
||||||
ValueError
|
|
||||||
`method` 不在支持列表里(报错信息会列出全部支持的方法)。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
论文里必须说明用了哪种填补、为什么。填 `mean` 之前先想一下
|
|
||||||
"这个缺失是不是非随机缺失(MNAR)",否则结论可能被填补方式带偏。
|
|
||||||
"""
|
|
||||||
if not isinstance(data,pd.DataFrame):
|
|
||||||
raise TypeError(f"data必须是pandas DataFrame,你传入了{type(data)}")
|
|
||||||
|
|
||||||
supported_method = ['mean','median','mode','ffill','interpolate']
|
|
||||||
if method not in supported_method:
|
|
||||||
raise ValueError(f"不支持的填补方式:{method},仅支持:{supported_method}")
|
|
||||||
|
|
||||||
# 上面已校验 method 合法,所以每条 return 之后不必再写 elif;
|
|
||||||
# 最后一行无条件 return 兜住仅剩的 interpolate,函数就没有"隐式返回 None"的路径了。
|
|
||||||
if method == 'mean':
|
|
||||||
return data.fillna(data.mean(numeric_only=True),**kwargs)
|
|
||||||
if method == 'median':
|
|
||||||
return data.fillna(data.median(numeric_only=True),**kwargs)
|
|
||||||
if method == 'mode':
|
|
||||||
# 只有这一支要逐列赋值,所以在这里单独复制一份,别就地改调用方的表
|
|
||||||
# (其余四支走 fillna / ffill / interpolate,本来就返回新对象)。
|
|
||||||
result = data.copy()
|
|
||||||
mode_df = result.mode()
|
|
||||||
for col in result.columns:
|
|
||||||
if mode_df[col].empty:
|
|
||||||
continue
|
|
||||||
result[col] = result[col].fillna(mode_df[col].iloc[0])
|
|
||||||
return result
|
|
||||||
if method == 'ffill':
|
|
||||||
return data.ffill(**kwargs)
|
|
||||||
return data.interpolate(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwargs: Any) -> Any:
|
|
||||||
"""标出离群点位置(返回布尔掩码,不直接删)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
data:
|
|
||||||
`pandas.DataFrame` / `pandas.Series`,或 `list` / `tuple` / `numpy.ndarray`。
|
|
||||||
method:
|
|
||||||
`"iqr"`:Q1-1.5·IQR 之外(`k` 可调,默认 1.5);
|
|
||||||
`"zscore"`:|z| > threshold(默认 3.0)。
|
|
||||||
**kwargs:
|
|
||||||
给对应方法用的参数:`k` 只对 `"iqr"` 生效,`threshold` 只对 `"zscore"` 生效。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
与原数据同形的布尔 DataFrame/ndarray,`True` 表示该点是离群点。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`data` 既不是 `pandas.DataFrame` / `pandas.Series`,也不是 `list` / `tuple` / `numpy.ndarray`。
|
|
||||||
ValueError
|
|
||||||
`method` 既不是 `"iqr"` 也不是 `"zscore"`。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
**只标不删**是刻意的:删点是有代价的判断,必须由人来做,
|
|
||||||
并且要在论文里写清楚删了几个、依据是什么。
|
|
||||||
|
|
||||||
非数值列**不参与统计**,掩码里恒为 `False`:均值和分位数对文本没有意义,
|
|
||||||
但"这一列没有离群点"比"整张表算不出来"有用得多。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> mask = detect_outliers([1, 2, 3, 4, 100], method="iqr")
|
|
||||||
>>> bool(mask[-1])
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
if not isinstance(data, (pd.DataFrame, pd.Series, list, tuple, np.ndarray)):
|
|
||||||
raise TypeError(f"data必须是pandas DataFrame/Series 或数组型,你传入了{type(data)}")
|
|
||||||
|
|
||||||
is_series = isinstance(data, pd.Series)
|
|
||||||
is_ld_array = isinstance(data, (list, tuple, np.ndarray)) and np.array(data).ndim == 1
|
|
||||||
|
|
||||||
if isinstance(data,pd.Series):
|
|
||||||
data = data.to_frame()
|
|
||||||
elif not isinstance(data,pd.DataFrame):
|
|
||||||
data = pd.DataFrame(data)
|
|
||||||
|
|
||||||
if method == 'iqr':
|
|
||||||
k = kwargs.get("k", 1.5)
|
|
||||||
elif method == 'zscore':
|
|
||||||
threshold = kwargs.get("threshold", 3.0)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"不支持的方法:{method},仅支持'iqr'或'zscore'")
|
|
||||||
|
|
||||||
numeric_cols = data.select_dtypes(include=['number']).columns
|
|
||||||
mask = pd.DataFrame(False,index=data.index,columns=data.columns)
|
|
||||||
# 取列子表得到的一定是 DataFrame,但 pandas 的类型存根只声明了「Hashable -> Series」,
|
|
||||||
# pyright 于是把 numeric 当成 Series,再把 `.std()` 的返回当成标量、判 `.replace` 非法。
|
|
||||||
# 这里按运行时的真值显式收窄,让下面几行继续参与类型检查。
|
|
||||||
numeric = cast(pd.DataFrame, data[numeric_cols])
|
|
||||||
|
|
||||||
if method == 'iqr':
|
|
||||||
Q1 = numeric.quantile(0.25)
|
|
||||||
Q3 = numeric.quantile(0.75)
|
|
||||||
IQR = Q3 - Q1
|
|
||||||
lower_bound = Q1 - k * IQR
|
|
||||||
upper_bound = Q3 + k * IQR
|
|
||||||
|
|
||||||
mask[numeric_cols] = (numeric < lower_bound) | (numeric > upper_bound)
|
|
||||||
|
|
||||||
elif method == 'zscore':
|
|
||||||
mean = numeric.mean()
|
|
||||||
std = numeric.std()
|
|
||||||
|
|
||||||
std = std.replace(0, np.nan)
|
|
||||||
z_scores = (numeric - mean) / std
|
|
||||||
mask[numeric_cols] = z_scores.abs() > threshold
|
|
||||||
|
|
||||||
if is_series:
|
|
||||||
return mask.iloc[:,0]
|
|
||||||
if is_ld_array:
|
|
||||||
return mask.values.flatten()
|
|
||||||
|
|
||||||
return mask
|
|
||||||
|
|
||||||
|
|
||||||
def winsorize(data: Any, lower: float = 0.01, upper: float = 0.99) -> Any:
|
|
||||||
"""缩尾:把超出分位数的值压到分位点,而不是删除。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
data:
|
|
||||||
`pandas.DataFrame` 或 `pandas.Series`。
|
|
||||||
lower:
|
|
||||||
下分位点,默认 0.01(1%)。
|
|
||||||
upper:
|
|
||||||
上分位点,默认 0.99(99%)。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
pandas.DataFrame | pandas.Series
|
|
||||||
与输入同类型,超出分位点的值被压到分位点上。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`data` 既不是 `pandas.DataFrame` 也不是 `pandas.Series`。
|
|
||||||
ValueError
|
|
||||||
分位数不满足 `0 < lower < upper < 1`。
|
|
||||||
"""
|
|
||||||
if not isinstance(data, (pd.DataFrame, pd.Series)):
|
|
||||||
raise TypeError(f"data必须是pandas DataFrame 或 Series,你传入了{type(data)}")
|
|
||||||
|
|
||||||
if not (0 < lower < upper < 1):
|
|
||||||
raise ValueError(f"分位数必须满足0 < lower < upper < 1,你传入了lower={lower},upper={upper}")
|
|
||||||
|
|
||||||
if isinstance(data,pd.Series):
|
|
||||||
if not pd.api.types.is_numeric_dtype(data):
|
|
||||||
return data.copy()
|
|
||||||
lower_val = data.quantile(lower)
|
|
||||||
upper_val = data.quantile(upper)
|
|
||||||
return data.clip(lower=float(lower_val),upper=float(upper_val))
|
|
||||||
|
|
||||||
numeric_cols = data.select_dtypes(include=['number']).columns
|
|
||||||
if len(numeric_cols) == 0:
|
|
||||||
return data.copy()
|
|
||||||
|
|
||||||
lower_bounds = data[numeric_cols].quantile(lower)
|
|
||||||
upper_bounds = data[numeric_cols].quantile(upper)
|
|
||||||
|
|
||||||
result = data.copy()
|
|
||||||
|
|
||||||
result[numeric_cols] = result[numeric_cols].clip(
|
|
||||||
lower=lower_bounds,
|
|
||||||
upper=upper_bounds,
|
|
||||||
axis=1
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"""读写表格。
|
|
||||||
|
|
||||||
只保留两个函数:`load_table` / `save_table` 按扩展名分派。
|
|
||||||
原建议里的 `load_csv` `load_excel` `save_csv` `save_excel` 四个函数
|
|
||||||
只是在转发 pandas,合并后调用点更短。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
__all__ = ["load_table", "save_table"]
|
|
||||||
|
|
||||||
|
|
||||||
def load_table(path: str | Path, **kwargs: Any) -> pd.DataFrame:
|
|
||||||
"""按扩展名自动选择读取方式,返回 DataFrame。
|
|
||||||
|
|
||||||
约定:
|
|
||||||
* `.csv` 默认做编码探测(utf-8 → utf-8-sig → gbk → gb18030),
|
|
||||||
国内赛题数据常见 GBK;
|
|
||||||
* `.xlsx` / `.xls` 需要 `openpyxl`(`pip install "mcm-toolkit[excel]"`);
|
|
||||||
* `na_values` 默认包含空串、`"NA"`、`"N/A"`、`"#N/A"`、`"null"`、`"-"`。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
path:
|
|
||||||
文件路径。扩展名决定用哪个读取器,大小写不敏感。
|
|
||||||
**kwargs:
|
|
||||||
原样转交给 `pandas.read_csv` / `pandas.read_excel`。
|
|
||||||
`.csv` 传了 `encoding=` 就照办,不再探测;不传则按上面的顺序逐个试。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
FileNotFoundError
|
|
||||||
文件不存在。`.csv` 的自动探测分支会把原始路径包进中文提示里。
|
|
||||||
ValueError
|
|
||||||
后缀不在 `.csv` / `.xlsx` / `.xls` 里;
|
|
||||||
`.csv` 在候选编码下全部解码失败;
|
|
||||||
或 pandas 自身的解析错误(`pandas.errors.ParserError` 也是 `ValueError` 的子类,
|
|
||||||
报错信息原样抛出——它比转述的更有用)。
|
|
||||||
ImportError
|
|
||||||
`.xlsx` / `.xls` 但缺可选依赖(`openpyxl`,读旧 `.xls` 另需 `xlrd`),
|
|
||||||
见 docs/02-architecture.md §4.6:可选依赖的报错必须能直接看懂。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> df = load_table("data.csv") # doctest: +SKIP
|
|
||||||
>>> df.shape # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
path = Path(path)
|
|
||||||
suffix = path.suffix.lower()
|
|
||||||
|
|
||||||
encodings = ['utf-8','utf-8-sig','gbk','gb18030']
|
|
||||||
kwargs.setdefault('na_values',['','NA','N/A','#N/A','null','-'])
|
|
||||||
|
|
||||||
if suffix == ".csv":
|
|
||||||
if 'encoding' in kwargs:
|
|
||||||
return pd.read_csv(path,**kwargs)
|
|
||||||
for enc in encodings:
|
|
||||||
try:
|
|
||||||
return pd.read_csv(path,encoding=enc,**kwargs)
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
continue
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise FileNotFoundError(f"在这里找不到文件:{path}") from None
|
|
||||||
raise ValueError(f"无法解码文件,目前支持解码方式:{encodings}")
|
|
||||||
if suffix in ['.xlsx','.xls']:
|
|
||||||
return pd.read_excel(path,**kwargs)
|
|
||||||
raise ValueError(f"不支持的文件后缀:{suffix}")
|
|
||||||
|
|
||||||
|
|
||||||
def save_table(df: pd.DataFrame, path: str | Path, index: bool = False, **kwargs: Any) -> Path:
|
|
||||||
"""保存 DataFrame,返回实际写入的路径。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
df:
|
|
||||||
要写出的表。必须是 `pandas.DataFrame`——`Series` 也不行,先 `.to_frame()`。
|
|
||||||
path:
|
|
||||||
目标路径。扩展名决定用哪个写入器,大小写不敏感;父目录不存在时自动创建。
|
|
||||||
index:
|
|
||||||
是否把行索引一并写进文件,默认 `False`。
|
|
||||||
**kwargs:
|
|
||||||
原样转交给 `pandas.DataFrame.to_csv` / `to_excel`。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`df` 不是 `pandas.DataFrame`。
|
|
||||||
ValueError
|
|
||||||
后缀不在 `.csv` / `.xlsx` / `.xls` 里。
|
|
||||||
ImportError
|
|
||||||
写 `.xlsx` / `.xls` 但缺 `openpyxl`。
|
|
||||||
"""
|
|
||||||
path = Path(path)
|
|
||||||
suffix = path.suffix.lower()
|
|
||||||
|
|
||||||
if not isinstance(df,pd.DataFrame):
|
|
||||||
raise TypeError(f"df 必须是一个pandas DataFrame,但是你上传了{type(df)}")
|
|
||||||
|
|
||||||
# 校验必须排在副作用(建目录)之前:否则传个 .txt 会先建出一个空目录再抛错。
|
|
||||||
if suffix not in ('.csv','.xlsx','.xls'):
|
|
||||||
raise ValueError(f"不支持的文件后缀:{suffix}")
|
|
||||||
|
|
||||||
path.parent.mkdir(parents=True,exist_ok=True)
|
|
||||||
|
|
||||||
if suffix == '.csv':
|
|
||||||
df.to_csv(path,index=index,**kwargs)
|
|
||||||
else:
|
|
||||||
df.to_excel(path,index=index,**kwargs)
|
|
||||||
return path
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""数据集划分与重采样。
|
|
||||||
|
|
||||||
原建议里的 `sample_with_replacement` / `sample_without_replacement`
|
|
||||||
就是 `rng.choice(..., replace=)` 一行,已删;真正需要的是"划分"和"bootstrap"。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = ["bootstrap_sample", "kfold_split", "train_test_split"]
|
|
||||||
|
|
||||||
|
|
||||||
def train_test_split(
|
|
||||||
*arrays: Any,
|
|
||||||
test_size: float = 0.2,
|
|
||||||
shuffle: bool = True,
|
|
||||||
stratify: Any = None,
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> list[Any]:
|
|
||||||
"""按行划分训练/测试集。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=0) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L0)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def kfold_split(n_samples: int, n_splits: int = 5, shuffle: bool = True, random_state: int | None = None) -> list[tuple[Any, Any]]:
|
|
||||||
"""返回 `n_splits` 组 (train_idx, test_idx)。"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_sample(data: Any, n_resamples: int = 10000, random_state: int | None = None) -> Any:
|
|
||||||
"""有放回重采样,返回形状 `(n_resamples, n)` 的重采样矩阵。
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
`n_resamples=10000` 且 `n` 很大时这个矩阵会吃内存。
|
|
||||||
只是想算区间的话用 :func:`mcm_toolkit.stats.bootstrap_ci`,
|
|
||||||
它内部按块处理,不落地整个矩阵。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
"""变换与无量纲化。
|
|
||||||
|
|
||||||
**重要约定(见 docs/02-architecture.md §4.4)**:
|
|
||||||
本模块只做"无量纲化",**不改变指标方向**。
|
|
||||||
效益型 / 成本型的方向问题归 `decision` 包处理,用 `benefit_columns` 显式声明,
|
|
||||||
不允许默认假设"越大越好"——评价类题目翻车基本都翻在这里。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from ..core.validate import as_array
|
|
||||||
|
|
||||||
__all__ = ["log_transform", "normalize", "one_hot_encode", "standardize"]
|
|
||||||
|
|
||||||
|
|
||||||
def normalize(x: Any, method: str = "minmax", feature_range: tuple[float, float] = (0.0, 1.0)) -> Any:
|
|
||||||
"""无量纲化到给定区间。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
x:
|
|
||||||
数值型标量 / list / tuple / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`;
|
|
||||||
多维输入一律**按列**(`axis=0`)处理。
|
|
||||||
method:
|
|
||||||
`"minmax"`:`(x - min) / (max - min)`,线性压缩到 `feature_range`;
|
|
||||||
`"vector"`:`x / ||x||`,保持"份额"含义(熵权法前处理常用);
|
|
||||||
`"max"`:`x / max(x)`。
|
|
||||||
feature_range:
|
|
||||||
仅 `method="minmax"` 时生效的目标区间,形如 `(low, high)`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
numpy.ndarray / pandas.Series / pandas.DataFrame
|
|
||||||
与 `x` 同类型:DataFrame 进 DataFrame 出(列名、索引保持不变),
|
|
||||||
Series 进 Series 出,其余返回 `numpy.ndarray`。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
`method` 不在支持列表内,或 `feature_range[0] >= feature_range[1]`。
|
|
||||||
TypeError
|
|
||||||
`x` 类型不支持,或 `feature_range` 不是二元组。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
常量列(`max == min`)不产生 nan / inf,固定压到 `feature_range` 的下界。
|
|
||||||
minmax 对离群值极度敏感:一个 100 倍大的异常值会把其余所有点压成一条线。
|
|
||||||
先跑 `detect_outliers` 再决定。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> normalize([1, 2, 3]).tolist()
|
|
||||||
[0.0, 0.5, 1.0]
|
|
||||||
"""
|
|
||||||
supported_method = ["minmax", "vector", "max"]
|
|
||||||
if method not in supported_method:
|
|
||||||
raise ValueError(f"不支持的方法:{method},仅支持{supported_method}")
|
|
||||||
|
|
||||||
if method == "minmax":
|
|
||||||
if not isinstance(feature_range, tuple) or len(feature_range) != 2:
|
|
||||||
raise TypeError(f"feature_range必须是形如(low, high)的二元组,你传入了{feature_range}")
|
|
||||||
if feature_range[0] >= feature_range[1]:
|
|
||||||
raise ValueError(f"feature_range的下界必须小于上界,你传入了{feature_range}")
|
|
||||||
|
|
||||||
# numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。
|
|
||||||
arr, kind = as_array(x, numeric_only=False)
|
|
||||||
|
|
||||||
if method == "minmax":
|
|
||||||
x_min = np.min(arr, axis=0)
|
|
||||||
x_max = np.max(arr, axis=0)
|
|
||||||
x_range = np.where(x_max - x_min == 0, 1.0, x_max - x_min)
|
|
||||||
|
|
||||||
scaled = (arr - x_min) / x_range
|
|
||||||
result = scaled * (feature_range[1] - feature_range[0]) + feature_range[0]
|
|
||||||
elif method == "vector":
|
|
||||||
norm = np.linalg.norm(arr, axis=0)
|
|
||||||
norm = np.where(norm == 0, 1.0, norm)
|
|
||||||
result = arr / norm
|
|
||||||
else:
|
|
||||||
# 上面已校验 method 合法,所以这里不必再写 elif,兜住仅剩的 "max"
|
|
||||||
x_max = np.max(arr, axis=0)
|
|
||||||
x_max = np.where(x_max == 0, 1.0, x_max)
|
|
||||||
result = arr / x_max
|
|
||||||
|
|
||||||
if kind == "frame":
|
|
||||||
return pd.DataFrame(result, index=x.index, columns=x.columns)
|
|
||||||
if kind == "series":
|
|
||||||
return pd.Series(result, index=x.index, name=x.name)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def standardize(x: Any, ddof: int = 0) -> Any:
|
|
||||||
"""标准化 `(x - mean) / std`,输出均值 0、标准差 1。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
x:
|
|
||||||
数值型标量 / list / tuple / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`;
|
|
||||||
多维输入一律**按列**(`axis=0`)处理。
|
|
||||||
ddof:
|
|
||||||
标准差的自由度修正,与 `numpy.std` 同名参数含义一致;`0` 为总体标准差。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
numpy.ndarray / pandas.Series / pandas.DataFrame
|
|
||||||
与 `x` 同类型,DataFrame 的列名与索引保持不变。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`x` 类型不支持。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
常量列(`std == 0`)不产生 nan / inf,固定输出 0,与 `sklearn` 的
|
|
||||||
`StandardScaler` 对零方差列的处理一致。
|
|
||||||
"""
|
|
||||||
# numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。
|
|
||||||
arr, kind = as_array(x, numeric_only=False)
|
|
||||||
|
|
||||||
mean = np.mean(arr, axis=0)
|
|
||||||
|
|
||||||
std = np.std(arr, axis=0, ddof=ddof)
|
|
||||||
|
|
||||||
std = np.where(std == 0, 1.0, std)
|
|
||||||
|
|
||||||
result = (arr - mean) / std
|
|
||||||
|
|
||||||
if kind == "frame":
|
|
||||||
return pd.DataFrame(result, index=x.index, columns=x.columns)
|
|
||||||
if kind == "series":
|
|
||||||
return pd.Series(result, index=x.index, name=x.name)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def log_transform(x: Any, offset: float | None = None) -> Any:
|
|
||||||
"""对数变换,用于压缩右偏分布。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
x:
|
|
||||||
数值型标量 / list / tuple / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`。
|
|
||||||
offset:
|
|
||||||
平移量,算的是 `log(x + offset)`。
|
|
||||||
`None` 时自动取 `1 - min(x)`(当 `min(x) <= 0`),否则取 `0`,以保证定义域。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
numpy.ndarray / pandas.Series / pandas.DataFrame
|
|
||||||
与 `x` 同类型,DataFrame 的列名与索引保持不变。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`x` 类型不支持。
|
|
||||||
ValueError
|
|
||||||
显式传入的 `offset` 会让 `x + offset <= 0`,即破坏 `log` 的定义域。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> log_transform([1.0, 2.718281828459045]).round(3).tolist()
|
|
||||||
[0.0, 1.0]
|
|
||||||
"""
|
|
||||||
# numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。
|
|
||||||
arr, kind = as_array(x, numeric_only=False)
|
|
||||||
|
|
||||||
if offset is None:
|
|
||||||
min_val = np.min(arr)
|
|
||||||
offset = 1.0 - min_val if min_val <= 0 else 0.0
|
|
||||||
elif np.min(arr) + offset <= 0:
|
|
||||||
raise ValueError(
|
|
||||||
f"offset={offset} 会破坏 log 定义域: x + offset 的最小值为 {np.min(arr) + offset},必须大于 0"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = np.log(arr + offset)
|
|
||||||
|
|
||||||
if kind == "frame":
|
|
||||||
return pd.DataFrame(result, index=x.index, columns=x.columns)
|
|
||||||
if kind == "series":
|
|
||||||
return pd.Series(result, index=x.index, name=x.name)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def one_hot_encode(data: Any, columns: list[str] | None = None, drop_first: bool = False) -> Any:
|
|
||||||
"""类别变量独热编码。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
data:
|
|
||||||
要编码的表,必须是 `pandas.DataFrame`。
|
|
||||||
columns:
|
|
||||||
要编码的列名列表;`None` 表示自动识别所有文本 / 类别列
|
|
||||||
(`object` / `category` / `str` / `string` 四种 dtype)。
|
|
||||||
drop_first:
|
|
||||||
是否丢掉每个类别变量的第一个水平,避免哑变量陷阱(等价 `pandas.get_dummies` 同名参数)。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
pandas.DataFrame
|
|
||||||
编码后的新表,**原表不改**;新哑变量列形如 `city_A`。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`data` 不是 `pandas.DataFrame`。
|
|
||||||
ValueError
|
|
||||||
`columns` 里出现了原表中不存在的列名。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
这里**不能**把结果再按 `data.columns` 重新包一层 `DataFrame`:
|
|
||||||
哑变量列名(`city_A`)和原列名(`city`)对不上,那样会把编码结果整列丢掉。
|
|
||||||
"""
|
|
||||||
if not isinstance(data, pd.DataFrame):
|
|
||||||
raise TypeError(f"data必须是 pandas DataFrame, 你传入了{type(data)}")
|
|
||||||
|
|
||||||
if columns is not None:
|
|
||||||
missing_cols = [col for col in columns if col not in data.columns]
|
|
||||||
if missing_cols:
|
|
||||||
raise ValueError(f"指定的列不存在于数据中:{missing_cols}")
|
|
||||||
cols_to_encode = columns
|
|
||||||
else:
|
|
||||||
# "str" / "string" 必须显式写上:pandas 3 起字符串列的 dtype 是 "str",
|
|
||||||
# 只写 "object" 目前还能选中,但会发 Pandas4Warning,且官方声明未来移除该行为
|
|
||||||
# —— 到那天这里会**静默**一列都不编码,比报错难查得多。
|
|
||||||
cols_to_encode = data.select_dtypes(include=["object", "category", "str", "string"]).columns.tolist()
|
|
||||||
|
|
||||||
return pd.get_dummies(data, columns=cols_to_encode, drop_first=drop_first)
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"""decision —— 评价与决策。
|
|
||||||
|
|
||||||
中文赛高频模块(AHP / TOPSIS / 熵权 / 灰色)。
|
|
||||||
原建议列了 8 个子模块,其中 `dea_efficiency`(要解一堆 LP,低频)与
|
|
||||||
`fuzzy_comprehensive_evaluation`(隶属度函数没有通用形式,做不了通用封装)已删。
|
|
||||||
|
|
||||||
**使用顺序很重要**:先 `normalize`(无量纲化)→ 再定方向 `benefit_columns`
|
|
||||||
→ 再 `entropy_weights`/`ahp_weights` → 最后 `topsis`。
|
|
||||||
跳过第一步直接算权重,是评价类题目最常见的翻车方式。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .ahp import ahp_consistency_ratio, ahp_weights
|
|
||||||
from .grey import gm11_fit_predict, grey_relational_analysis
|
|
||||||
from .topsis import topsis
|
|
||||||
from .weighting import critic_weights, entropy_weights, equal_weights
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"equal_weights",
|
|
||||||
"entropy_weights",
|
|
||||||
"critic_weights",
|
|
||||||
"ahp_weights",
|
|
||||||
"ahp_consistency_ratio",
|
|
||||||
"topsis",
|
|
||||||
"grey_relational_analysis",
|
|
||||||
"gm11_fit_predict",
|
|
||||||
]
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
"""层次分析法(AHP)。
|
|
||||||
|
|
||||||
不过度设计:原建议第 22 节给出的两个函数就是全部需要的东西。
|
|
||||||
关键在**一致性检验**——CR 不过关的权重不能拿去用,
|
|
||||||
这是 AHP 唯一真正的技术含量所在。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = ["ahp_consistency_ratio", "ahp_weights"]
|
|
||||||
|
|
||||||
|
|
||||||
def ahp_weights(matrix: Any) -> Any:
|
|
||||||
"""由判断矩阵求权重。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
matrix:
|
|
||||||
正互反判断矩阵(`a_ij = 1/a_ji`,`a_ii = 1`),Saaty 1-9 标度。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`params={"weights"}`,`metrics={"lambda_max", "CI", "CR", "consistent"}`。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
取权重有两种做法:
|
|
||||||
* **特征向量法**(`eigenvector`):求最大特征值对应的特征向量并归一化,理论严谨;
|
|
||||||
* **几何平均法**(`geometric`):各行元素连乘开 n 次方再归一化,手算/答辩时好用,
|
|
||||||
与特征向量法结果几乎一致。
|
|
||||||
本函数默认特征向量法,`method="geometric"` 可切换,两者都返回以便对照。
|
|
||||||
|
|
||||||
**CR > 0.1 一般判定为不一致**(n=2 时 CR 无意义,直接跳过)。
|
|
||||||
有人会"改矩阵直到 CR 过关",这在论文里必须交代调整依据,否则是学术不端。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> A = np.array([[1, 3, 5], [1/3, 1, 2], [1/5, 0.5, 1]])
|
|
||||||
>>> r = ahp_weights(A)
|
|
||||||
>>> bool(r.metrics["CR"] < 0.1)
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
# TODO(L1) ★ S 档:配完整函数卡片
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def ahp_consistency_ratio(matrix: Any) -> Any:
|
|
||||||
"""只算一致性指标,不返回权重。
|
|
||||||
|
|
||||||
单独提供它是因为:**拿到判断矩阵后第一步就该看 CR**,
|
|
||||||
不过关时先回去调整矩阵,权重算出来也用不了。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""灰色系统:关联分析与 GM(1,1) 预测。
|
|
||||||
|
|
||||||
中文建模赛特色方法。原建议第 23 节的方向没问题,函数名做了合并
|
|
||||||
(`gm11_fit` + `gm11_predict` → `gm11_fit_predict`,因为 GM(1,1) 极少只用一半)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = ["gm11_fit_predict", "grey_relational_analysis"]
|
|
||||||
|
|
||||||
|
|
||||||
def grey_relational_analysis(X: Any, reference: Any = None, rho: float = 0.5) -> Any:
|
|
||||||
"""灰色关联分析。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
reference:
|
|
||||||
参考序列。`None` 时按"每个指标取最优值"自动构造。
|
|
||||||
rho:
|
|
||||||
分辨系数,通常取 0.5。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是各方案与参考序列的关联系数、关联度与排名。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
关联度对 `rho` 敏感:`rho` 越小,分辨力越强但稳定性越差。
|
|
||||||
论文里应说明 `rho` 取值并做一次敏感性(`rho = 0.3 / 0.5 / 0.7`)。
|
|
||||||
另外它**要求所有指标同向且无量纲**,前置处理同熵权法。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = grey_relational_analysis([[1, 2], [3, 1], [2, 3]]) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def gm11_fit_predict(series: Any, steps: int = 1) -> Any:
|
|
||||||
"""GM(1,1) 灰色预测(拟合 + 预测一步到位)。
|
|
||||||
|
|
||||||
流程:一次累加生成(1-AGO)→ 建立白化微分方程 `dx/dt + ax = b`
|
|
||||||
→ 最小二乘估 `a, b` → 还原预测序列。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
steps:
|
|
||||||
向前预测步数。GM(1,1) 适合**短序列(4~10 个点)**的短期预测。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`params={"a", "b"}`,`metrics={"c_ratio", "precision"}`,
|
|
||||||
`data` 是含 `actual / fitted / forecast` 三列的 DataFrame。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
* **必须做后验差检验**:后验差比值 `C = S2/S1`(残差标准差 / 原序列标准差)
|
|
||||||
和 小误差概率 `P`。`C < 0.35` 一级,`< 0.5` 合格,`>= 0.65` 不能用。
|
|
||||||
只给预测值不给 `C`、`P` 是论文里的硬伤。
|
|
||||||
* GM(1,1) 本质是指数增长外推,对**先增后减**或波动序列效果很差,
|
|
||||||
用之前先看序列形状。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = gm11_fit_predict([2.87, 3.05, 3.24, 3.43, 3.64]) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L1) ★ S 档:配完整函数卡片
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""TOPSIS 逼近理想解排序。
|
|
||||||
|
|
||||||
原建议把标准化(`benefit_normalize` 等 4 个函数)单列一节,
|
|
||||||
这里合并进 `topsis` 的 `method` 参数——因为它们是同一个流程的步骤,
|
|
||||||
拆开反而容易出现"标准化用了一套、权重算的时候又换一套"的错位。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = ["topsis"]
|
|
||||||
|
|
||||||
|
|
||||||
def topsis(
|
|
||||||
X: Any,
|
|
||||||
weights: Any = None,
|
|
||||||
benefit_columns: Any = None,
|
|
||||||
method: str = "vector",
|
|
||||||
labels: Any = None,
|
|
||||||
) -> Any:
|
|
||||||
"""TOPSIS 综合评价。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
X:
|
|
||||||
决策矩阵,行 = 待评价方案,列 = 指标。
|
|
||||||
weights:
|
|
||||||
权重。`None` 时用等权(并在 `message` 里提示)。
|
|
||||||
benefit_columns:
|
|
||||||
效益型指标的列名/下标,其余按成本型处理。
|
|
||||||
**必填或显式全选**,不允许默认假设——方向搞反会让排名完全颠倒。
|
|
||||||
method:
|
|
||||||
列向量的无量纲化方式:`"vector"`(`x/√Σx²`,TOPSIS 原始做法,默认)
|
|
||||||
或 `"minmax"`。
|
|
||||||
labels:
|
|
||||||
方案名,用于结果表。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是含 `D+ / D- / closeness / rank` 四列、按 `rank` 排好的表,
|
|
||||||
`params["weights"]` 回显实际使用的权重。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
* 正理想解 = 各指标最优值,负理想解 = 各指标最劣值;
|
|
||||||
贴近度 `C = D- / (D+ + D-)`,**C 越大越好**。
|
|
||||||
* TOPSIS 的排名对权重敏感。论文里应附"权重扰动 ±10% 后排名是否稳定"的
|
|
||||||
敏感性分析——直接调 `solve.robustness_test`。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> r = topsis(np.array([[1., 2.], [3., 1.], [2., 3.]]), benefit_columns=[0, 1])
|
|
||||||
>>> list(r.data["rank"])
|
|
||||||
[2, 3, 1]
|
|
||||||
"""
|
|
||||||
# TODO(L1) ★ S 档:配完整函数卡片
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""客观赋权:熵权法、CRITIC。
|
|
||||||
|
|
||||||
★ `entropy_weights` 是 S 档,建议自己实现。
|
|
||||||
熵权法是中文建模赛出现频率最高的赋权方法,实现只有 20 行,
|
|
||||||
但"指标方向、无量纲化、对数 0 处理"三个坑必须理解清楚才能写对。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = ["critic_weights", "entropy_weights", "equal_weights"]
|
|
||||||
|
|
||||||
|
|
||||||
def equal_weights(n: int) -> Any:
|
|
||||||
"""等权,即 `1/n`。作为赋权结果的对照基线,论文里应该报。"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def entropy_weights(
|
|
||||||
X: Any,
|
|
||||||
benefit_columns: Any = None,
|
|
||||||
eps: float = 1e-12,
|
|
||||||
) -> Any:
|
|
||||||
"""熵权法。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
X:
|
|
||||||
决策矩阵,行 = 方案,列 = 指标。**必须已无量纲化且已做正向化**。
|
|
||||||
benefit_columns:
|
|
||||||
效益型(越大越好)指标的列名/下标。其余按成本型处理。
|
|
||||||
`None` 时假设全部是效益型,并在 `message` 里提示——避免静默搞错方向。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`params={"weights": 权重向量}`,
|
|
||||||
`extras["entropy"]` 是各指标的熵值,
|
|
||||||
`extras["redundancy"]` 是差异系数 `1 - e`(权重就来自它的归一化)。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
三个必须处理的细节:
|
|
||||||
1. **方向**:成本型指标要先正向化(`max - x` 或 `1/x`),否则权重完全错。
|
|
||||||
2. **量纲**:熵权法要求 `x >= 0`,且对量纲敏感——先做 min-max 或向量归一。
|
|
||||||
3. **零值**:`p·ln(p)` 在 `p=0` 时按 0 处理(极限为 0),不能直接算,否则出 NaN。
|
|
||||||
|
|
||||||
熵权法的缺点是**只看数据离散程度,不看指标的实际重要性**。
|
|
||||||
实践上常和 AHP 做组合赋权:`w = α·w_entropy + (1-α)·w_ahp`。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> r = entropy_weights(np.array([[1., 2.], [3., 1.], [2., 3.]]))
|
|
||||||
>>> bool(np.isclose(r.params["weights"].sum(), 1.0))
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
# TODO(L1) ★ S 档:配完整函数卡片
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def critic_weights(X: Any, benefit_columns: Any = None) -> Any:
|
|
||||||
"""CRITIC 赋权。
|
|
||||||
|
|
||||||
与熵权法的区别:熵权法只看"对比强度"(标准差),
|
|
||||||
CRITIC 还乘上"冲突性"(与其他指标的相关性)。
|
|
||||||
`C_j = σ_j · Σ_k (1 - r_jk)`,权重 = `C_j / ΣC`。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""models —— 建模:回归 / 时间序列 / 机器学习(薄封装)。
|
|
||||||
|
|
||||||
原建议把 `regression/` 和 `ml/` 拆成两个包,但两边的
|
|
||||||
`fit_linear` 与 `fit_linear_regression` 明显重复。
|
|
||||||
这里合并为一个包,共享一套 `fit / predict / score` 协议和一份指标实现。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .metrics import (
|
|
||||||
adjusted_r2,
|
|
||||||
compare_models,
|
|
||||||
mae,
|
|
||||||
mape,
|
|
||||||
r2_score,
|
|
||||||
rmse,
|
|
||||||
smape,
|
|
||||||
)
|
|
||||||
from .ml import cross_validate, fit_forest, fit_logistic, kmeans, pca
|
|
||||||
from .regression import fit_linear, fit_nonlinear, fit_polynomial, predict
|
|
||||||
from .timeseries import exponential_smoothing, fit_arima, forecast
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"fit_linear",
|
|
||||||
"fit_polynomial",
|
|
||||||
"fit_nonlinear",
|
|
||||||
"predict",
|
|
||||||
"exponential_smoothing",
|
|
||||||
"fit_arima",
|
|
||||||
"forecast",
|
|
||||||
"fit_forest",
|
|
||||||
"fit_logistic",
|
|
||||||
"kmeans",
|
|
||||||
"pca",
|
|
||||||
"cross_validate",
|
|
||||||
"rmse",
|
|
||||||
"mae",
|
|
||||||
"mape",
|
|
||||||
"smape",
|
|
||||||
"r2_score",
|
|
||||||
"adjusted_r2",
|
|
||||||
"compare_models",
|
|
||||||
]
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""模型协议。
|
|
||||||
|
|
||||||
所有 `fit_*` 返回的对象都满足这个协议,于是:
|
|
||||||
`compare_models({"linear": m1, "rf": m2}, X, y)`
|
|
||||||
才能对一批异构模型统一执行、统一打分。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Protocol, runtime_checkable
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
__all__ = ["Model"]
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class Model(Protocol):
|
|
||||||
"""`fit / predict / score` 三件套。"""
|
|
||||||
|
|
||||||
def fit(self, X: Any, y: Any, **kwargs: Any) -> Model:
|
|
||||||
"""拟合,返回 self(支持链式调用)。"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def predict(self, X: Any) -> np.ndarray:
|
|
||||||
"""预测。"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def score(self, X: Any, y: Any) -> dict[str, float]:
|
|
||||||
"""返回指标 dict(不是单个 float),便于直接拼成对比表。"""
|
|
||||||
...
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
"""回归/分类指标 + 模型对比。
|
|
||||||
|
|
||||||
原建议把这批函数同时放在 `regression/`、`utils/metrics.py` 和第 26 节的
|
|
||||||
`analysis/` 三处,这里统一到一处。
|
|
||||||
|
|
||||||
注意:本模块被 `models` 与 `solve` 共用,属于"公共数学",但为了少建一个包,
|
|
||||||
放在 `models` 下;`solve` 单向依赖 `models` 是允许的。
|
|
||||||
|
|
||||||
本模块的指标函数**只返回 `float`,不返回 `Result`**(架构 §4.1 只覆盖拟合/求解/
|
|
||||||
模拟/评价);入口非法一律抛异常,见各函数的 `Raises`。唯一的例外是
|
|
||||||
`compare_models`:它按架构 §4.3 返回一张 `DataFrame` 对比表。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from mcm_toolkit.core import as_array, get_rng
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"adjusted_r2",
|
|
||||||
"compare_models",
|
|
||||||
"mae",
|
|
||||||
"mape",
|
|
||||||
"r2_score",
|
|
||||||
"rmse",
|
|
||||||
"smape",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def verify_yt_yp(y_true: Any, y_pred: Any) -> tuple[np.ndarray, np.ndarray]:
|
|
||||||
"""收成同形的一维 `ndarray`,并拦掉形状 / 空 / NaN / Inf / 样本不足五类非法输入。
|
|
||||||
|
|
||||||
指标函数的第一道关:先在这里把非法输入拦掉,函数体里就只剩公式。
|
|
||||||
刻意**不删缺失值**(与 `as_array` 同一口径):删了会悄悄改变样本量,
|
|
||||||
指标就不可比了,所以含 `NaN` / `Inf` 直接报错(决策 48)。
|
|
||||||
**至少 2 个样本**:1 个点的误差指标没有统计意义(`r2_score` 更是直接无定义)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
y_true, y_pred:
|
|
||||||
`DataFrame` / `Series` / `ndarray` / `list` / `tuple`,按 `as_array` 的规则
|
|
||||||
收成一维浮点数组(`str` 会被白名单挡住并 `TypeError`)。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
(numpy.ndarray, numpy.ndarray)
|
|
||||||
校验通过后的 `(y_true, y_pred)`,两者形状相同。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
形状不一致;长度为 0 或 1(指标至少要 2 个样本);含 `NaN` 或 `Inf`。
|
|
||||||
"""
|
|
||||||
yt, _ = as_array(y_true, ndim=1)
|
|
||||||
yp, _ = as_array(y_pred, ndim=1)
|
|
||||||
|
|
||||||
if yt.shape != yp.shape:
|
|
||||||
raise ValueError(f"y_true 与 y_pred 的形状必须一致,你传入的是 {yt.shape} 和 {yp.shape}")
|
|
||||||
if len(yt) == 0:
|
|
||||||
raise ValueError("y_true 与 y_pred 都不能为空")
|
|
||||||
if np.isnan(yt).any() or np.isnan(yp).any():
|
|
||||||
raise ValueError("有空值(NaN),请清洗或补充后再计算")
|
|
||||||
if np.isinf(yt).any() or np.isinf(yp).any():
|
|
||||||
raise ValueError("有无限值(Inf),请检查数据来源,修复除以0或溢出问题")
|
|
||||||
if yt.ndim == 0 or yt.shape[0] <= 1:
|
|
||||||
raise ValueError(f"输入至少需要包含 2 个样本,当前长度为 {yt.shape[0] if yt.ndim > 0 else 1}")
|
|
||||||
|
|
||||||
return yt, yp
|
|
||||||
|
|
||||||
|
|
||||||
def rmse(y_true: Any, y_pred: Any) -> float:
|
|
||||||
"""均方根误差。与 y 同量纲、对大误差敏感,回归里最常用的第一指标。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
y_true, y_pred:
|
|
||||||
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
float
|
|
||||||
`sqrt(mean((y_true - y_pred) ** 2))`,越小越好,0 表示完美拟合。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。
|
|
||||||
"""
|
|
||||||
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
|
|
||||||
|
|
||||||
return float(np.sqrt(np.mean((yt - yp) ** 2)))
|
|
||||||
|
|
||||||
|
|
||||||
def mae(y_true: Any, y_pred: Any) -> float:
|
|
||||||
"""平均绝对误差。对离群值比 RMSE 稳健,和 RMSE 一起看能判断有无离群点。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
y_true, y_pred:
|
|
||||||
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
float
|
|
||||||
`mean(|y_true - y_pred|)`,越小越好。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。
|
|
||||||
"""
|
|
||||||
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
|
|
||||||
|
|
||||||
return float(np.mean(np.abs(yt - yp)))
|
|
||||||
|
|
||||||
|
|
||||||
def mape(y_true: Any, y_pred: Any) -> float:
|
|
||||||
"""平均绝对百分比误差。无量纲,可跨量纲比较;`y_true` 必须**全为正数**。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
y_true, y_pred:
|
|
||||||
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
float
|
|
||||||
`mean(|y_true - y_pred| / y_true)`,`0.1` 表示平均偏差 10%。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
未通过 :func:`verify_yt_yp`(形状 / 样本数 / `NaN` / `Inf`),
|
|
||||||
或 `y_true` 里有非正数(0 → 分母为 0;负数 → 百分比误差无意义;
|
|
||||||
这种数据请改用 :func:`smape`)。
|
|
||||||
"""
|
|
||||||
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
|
|
||||||
|
|
||||||
if np.any(yt <= 0):
|
|
||||||
raise ValueError("y_true 包含非正数(0或负数),MAPE 在数学上无定义,请清洗数据或改用 SMAPE。")
|
|
||||||
return float(np.mean(np.abs(yt - yp) / yt))
|
|
||||||
|
|
||||||
|
|
||||||
def smape(y_true: Any, y_pred: Any) -> float:
|
|
||||||
"""对称 MAPE。`y_true` / `y_pred` 含 0 或为 0 都安全,是 MAPE 的替代品。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
y_true, y_pred:
|
|
||||||
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
float
|
|
||||||
`mean(2 * |y_true - y_pred| / (|y_true| + |y_pred|))`。
|
|
||||||
分子分母同时为 0 的点**记 0**(该点无偏差可言),不计入分母。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。
|
|
||||||
"""
|
|
||||||
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
|
|
||||||
|
|
||||||
denom = np.abs(yt) + np.abs(yp)
|
|
||||||
# 分母为 0 的点记 0。注意 np.where 的两条分支**都会被求值**,直接写除式会触发
|
|
||||||
# "invalid value encountered in divide" 的 RuntimeWarning,而本项目 pytest 开了
|
|
||||||
# filterwarnings=error::RuntimeWarning,那样测试会直接算失败;所以用 where= 掩码。
|
|
||||||
ratio = np.divide(2 * np.abs(yt - yp), denom, out=np.zeros_like(denom), where=denom != 0.0)
|
|
||||||
return float(np.mean(ratio))
|
|
||||||
|
|
||||||
|
|
||||||
def r2_score(y_true: Any, y_pred: Any) -> float:
|
|
||||||
"""决定系数 R²。加变量一定会升高,跨模型比较要看 :func:`adjusted_r2`。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
y_true, y_pred:
|
|
||||||
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
float
|
|
||||||
`1 - SS_res / SS_tot`,其中 `SS_res = sum((y_true - y_pred) ** 2)`、
|
|
||||||
`SS_tot = sum((y_true - mean(y_true)) ** 2)`,基准是 `y_true` 的均值。
|
|
||||||
完美拟合约等于 1,比"直接取均值"还差时为负。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
未通过 :func:`verify_yt_yp`(形状 / 样本数 / `NaN` / `Inf`),
|
|
||||||
或 `y_true` 是常量(`SS_tot = 0`,分母为 0)。
|
|
||||||
"""
|
|
||||||
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
|
|
||||||
|
|
||||||
ss_res = np.sum((yt - yp) ** 2)
|
|
||||||
ss_tot = np.sum((yt - np.mean(yt)) ** 2)
|
|
||||||
|
|
||||||
if ss_tot == 0:
|
|
||||||
raise ValueError("y_true 是常量,SS_tot = 0,无法计算 R²")
|
|
||||||
return float(1 - (ss_res / ss_tot))
|
|
||||||
|
|
||||||
|
|
||||||
def adjusted_r2(y_true: Any, y_pred: Any, n_features: int | np.integer) -> float:
|
|
||||||
"""调整 R²,按自由度惩罚变量个数;同一样本量下比 R² 公平。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
y_true, y_pred:
|
|
||||||
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
|
|
||||||
n_features:
|
|
||||||
**自变量个数,不含截距**(`y = 2x + 1` 只有 1 个自变量)。
|
|
||||||
必须为正整数;`bool` 会被挡掉(`True` 看着像 1,但不是个数)。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
float
|
|
||||||
`1 - (1 - R²) * (n - 1) / (n - n_features - 1)`,`n = len(y_true)`。
|
|
||||||
比 R² 更保守:`n_features` 越大惩罚越重,所以它才是跨模型比较该看的那个。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`n_features` 不是整数(含 `bool`、`float`、`np.float64`)。
|
|
||||||
ValueError
|
|
||||||
`n_features <= 0`;`n <= n_features + 1`(自由度为 0,分母为 0);
|
|
||||||
或 `y_true` / `y_pred` 未通过 :func:`verify_yt_yp`(形状 / 样本数 / `NaN` / `Inf`)。
|
|
||||||
"""
|
|
||||||
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
|
|
||||||
|
|
||||||
if isinstance(n_features, bool) or not isinstance(n_features, (int, np.integer)):
|
|
||||||
raise TypeError(f"n_features 必须是正整数,你传入了 {type(n_features).__name__}")
|
|
||||||
if n_features <= 0:
|
|
||||||
raise ValueError(f"n_features 必须是正整数,你传入了 {n_features}")
|
|
||||||
|
|
||||||
r2 = r2_score(yt, yp)
|
|
||||||
n = len(yt)
|
|
||||||
|
|
||||||
if n - n_features - 1 <= 0:
|
|
||||||
raise ValueError(f"样本量太小(n={n},自变量 {n_features} 个),分母会变成 0 或负数")
|
|
||||||
|
|
||||||
return float(1 - ((1 - r2) * (n - 1) / (n - 1 - n_features)))
|
|
||||||
|
|
||||||
|
|
||||||
# 指标名 → 函数。**别名表**就在这里:`"r2"` 与 `"r2_score"` 是同一个函数的两个名字,
|
|
||||||
# 传哪个名字,输出表里的列就叫哪个名字(模型的 `score()` 习惯用 `"r2"`,函数本名是
|
|
||||||
# `r2_score`,两个都收,省得记)。要加指标就往这个 dict 里添一行。
|
|
||||||
#
|
|
||||||
# `adjusted_r2` **不在这里**:它还要 `n_features`(自变量个数),`compare_models` 不知道
|
|
||||||
# 每个模型实际用了几个自变量,硬塞进来只会算错。单个模型要调整 R² 就自己调它。
|
|
||||||
_METRIC_FUNCS: dict[str, Callable[[Any, Any], float]] = {
|
|
||||||
"rmse": rmse,
|
|
||||||
"mae": mae,
|
|
||||||
"mape": mape,
|
|
||||||
"smape": smape,
|
|
||||||
"r2": r2_score,
|
|
||||||
"r2_score": r2_score,
|
|
||||||
}
|
|
||||||
|
|
||||||
# "越大越好"的指标:排序方向要反过来。其余(误差类)一律越小越好。
|
|
||||||
_HIGHER_IS_BETTER = frozenset({"r2", "r2_score"})
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_metrics(metrics: Any) -> dict[str, Callable[[Any, Any], float]]:
|
|
||||||
"""把指标名列表翻成 `{列名: 函数}`,顺手拦掉不认识的名字和重复的别名。"""
|
|
||||||
if isinstance(metrics, str):
|
|
||||||
raise TypeError("metrics 是一**组**指标名,写成 metrics=('rmse', 'r2'),不要传单个字符串")
|
|
||||||
if not isinstance(metrics, (list, tuple)):
|
|
||||||
raise TypeError(f"metrics 必须是 tuple / list,你传入了 {type(metrics).__name__}")
|
|
||||||
|
|
||||||
resolved: dict[str, Callable[[Any, Any], float]] = {}
|
|
||||||
for name in metrics:
|
|
||||||
fn = _METRIC_FUNCS.get(name)
|
|
||||||
if fn is None:
|
|
||||||
raise ValueError(f"不认识的指标名 {name!r};可选:{sorted(_METRIC_FUNCS)}")
|
|
||||||
if fn in resolved.values():
|
|
||||||
raise ValueError(f"{name!r} 与前面某个指标是同一个函数(别名),只挑一个传")
|
|
||||||
resolved[name] = fn
|
|
||||||
|
|
||||||
if not resolved:
|
|
||||||
raise ValueError("metrics 至少要有一个指标")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def compare_models(
|
|
||||||
models: dict[str, Any],
|
|
||||||
X: Any,
|
|
||||||
y: Any,
|
|
||||||
metrics: tuple[str, ...] = ("rmse", "mae", "r2"),
|
|
||||||
cv: int = 5,
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""把若干模型放在一起打分,返回对比表。
|
|
||||||
|
|
||||||
这是赛题里最省时间的函数之一。队友问"要不要上复杂模型"时,
|
|
||||||
跑一次这个就能用数字回答,不用争论半小时。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
models:
|
|
||||||
`{"linear": m1, "poly3": m2, "random_forest": m3}`。
|
|
||||||
每个模型需满足 :class:`mcm_toolkit.models.base.Model` 协议(有 `fit` / `predict`)。
|
|
||||||
**传模型对象本身,不是拟合结果** —— 拟合由本函数自己做。
|
|
||||||
X:
|
|
||||||
二维设计矩阵 `(n_samples, n_features)`;一列的 `x` 要自己 `x.reshape(-1, 1)`。
|
|
||||||
y:
|
|
||||||
一维真值,与 `X` 行数相同。
|
|
||||||
metrics:
|
|
||||||
要打分的指标名。可用 `"rmse"` / `"mae"` / `"mape"` / `"smape"` / `"r2"` / `"r2_score"`;
|
|
||||||
**`"r2"` 与 `"r2_score"` 是同一个函数的别名**(传哪个、列名就叫哪个),两个一起传会报错。
|
|
||||||
表按**第一个指标**排序:误差类升序(越小越好),R² 降序。
|
|
||||||
cv:
|
|
||||||
交叉验证折数;`cv=0` 表示在全部数据上拟合后打分(只看拟合能力时用)。
|
|
||||||
`cv >= 2` 时报的是**折间均值**,每折至少 2 个样本(指标本身的入口要求)。
|
|
||||||
**时序数据不要用它** —— 随机划分会让未来信息漏进训练集(要用滚动窗口)。
|
|
||||||
random_state:
|
|
||||||
随机种子(`cv >= 2` 时决定折怎么分)。传整数可复现;`None` 时跟随
|
|
||||||
`core.set_seed` 的全局流。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
pandas.DataFrame:行 = 模型(`models` 的键),列 = 指标(`metrics` 里的名字)。
|
|
||||||
按第一个指标排好序,可直接 `save_table(tbl, "compare.tex", fmt="latex")` 贴进论文。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`models` 不是 dict、`cv` 不是整数、`metrics` 传了单个字符串,
|
|
||||||
或某个模型没有 `fit` / `predict`。
|
|
||||||
ValueError
|
|
||||||
`models` 为空、指标名不认识、两个指标是同一个别名、
|
|
||||||
`X` / `y` 未通过 `as_array`、样本数不一致、`cv` 取值不可能分折。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
分折是**用 numpy 手写的 K 折交叉验证**(`get_rng(random_state).permutation` +
|
|
||||||
`np.array_split`),所以不依赖 sklearn —— 比赛现场没装 `ml` 那组 extras 也能跑。
|
|
||||||
指标自己的入口要求照样生效:`mape` 要求 `y_true` 全为正数,含 0 或负数的数据改用 `smape`。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> tbl = compare_models({"linear": m1, "poly": m2}, X, y, cv=0) # doctest: +SKIP
|
|
||||||
>>> tbl.index[0] # doctest: +SKIP
|
|
||||||
'poly'
|
|
||||||
"""
|
|
||||||
if not isinstance(models, dict):
|
|
||||||
raise TypeError(f"models 必须是 {{名字: 模型}} 的字典,你传入了 {type(models).__name__}")
|
|
||||||
if not models:
|
|
||||||
raise ValueError("models 不能为空,至少给一个模型")
|
|
||||||
for name, model in models.items():
|
|
||||||
if not (hasattr(model, "fit") and hasattr(model, "predict")):
|
|
||||||
raise TypeError(
|
|
||||||
f"模型 {name!r} 不满足 Model 协议:需要 fit / predict 两个方法;"
|
|
||||||
f"你传入了 {type(model).__name__}(注意:传模型对象,别传拟合结果)"
|
|
||||||
)
|
|
||||||
|
|
||||||
resolved = _resolve_metrics(metrics)
|
|
||||||
|
|
||||||
X_arr, _ = as_array(X, ndim=2)
|
|
||||||
y_arr, _ = as_array(y, ndim=1)
|
|
||||||
n = y_arr.shape[0]
|
|
||||||
if X_arr.shape[0] != n:
|
|
||||||
raise ValueError(f"X 与 y 的样本数必须一致,你传入的是 {X_arr.shape[0]} 和 {n}")
|
|
||||||
if n < 2:
|
|
||||||
raise ValueError(f"样本太少:至少要 2 个,当前 n={n}")
|
|
||||||
|
|
||||||
if isinstance(cv, bool) or not isinstance(cv, (int, np.integer)):
|
|
||||||
raise TypeError(f"cv 必须是整数,你传入了 {type(cv).__name__}")
|
|
||||||
if cv < 0:
|
|
||||||
raise ValueError(f"cv 不能为负数:0 表示全量打分,你传入了 {cv}")
|
|
||||||
if cv == 1:
|
|
||||||
raise ValueError("cv=1 没有意义:要么 cv=0(全量拟合后打分),要么 cv>=2(分折)")
|
|
||||||
if cv > 0 and cv > n // 2:
|
|
||||||
raise ValueError(
|
|
||||||
f"cv={cv} 太大:n={n}、每折至少要 2 个样本,最多只能分 {n // 2} 折;"
|
|
||||||
"样本太少的话改用 cv=0(全量拟合后打分)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if cv == 0:
|
|
||||||
folds = [(np.arange(n), np.arange(n))]
|
|
||||||
else:
|
|
||||||
index = get_rng(random_state).permutation(n)
|
|
||||||
blocks = np.array_split(index, cv)
|
|
||||||
folds = [
|
|
||||||
(np.concatenate([blocks[j] for j in range(cv) if j != k]), blocks[k])
|
|
||||||
for k in range(cv)
|
|
||||||
]
|
|
||||||
|
|
||||||
scores: dict[str, dict[str, float]] = {}
|
|
||||||
for name, model in models.items():
|
|
||||||
per_fold: dict[str, list[float]] = {column: [] for column in resolved}
|
|
||||||
for train_idx, test_idx in folds:
|
|
||||||
model.fit(X_arr[train_idx], y_arr[train_idx])
|
|
||||||
y_pred = np.asarray(model.predict(X_arr[test_idx]))
|
|
||||||
for column, fn in resolved.items():
|
|
||||||
per_fold[column].append(fn(y_arr[test_idx], y_pred))
|
|
||||||
scores[name] = {column: float(np.mean(vals)) for column, vals in per_fold.items()}
|
|
||||||
|
|
||||||
table = pd.DataFrame.from_dict(scores, orient="index")
|
|
||||||
first = next(iter(resolved))
|
|
||||||
return table.sort_values(first, ascending=first not in _HIGHER_IS_BETTER)
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""机器学习薄封装。
|
|
||||||
|
|
||||||
对应原建议的 B 类。规模要控制住:
|
|
||||||
第一版只封 commonly-used 的四个模型 + 两个工具函数,
|
|
||||||
不追求把 sklearn 的 estimator 列表抄一遍。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = ["cross_validate", "fit_forest", "fit_logistic", "kmeans", "pca"]
|
|
||||||
|
|
||||||
|
|
||||||
def fit_forest(X: Any, y: Any, task: str = "auto", n_estimators: int = 300, random_state: int | None = None, **kwargs: Any) -> Any:
|
|
||||||
"""随机森林(分类或回归)。
|
|
||||||
|
|
||||||
`task="auto"`:按 y 的取值类型自动判断分类还是回归。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
随机森林最大的价值不是精度,而是 **feature_importances_**——
|
|
||||||
赛题论文里"哪些因素最重要"这一节经常靠它。
|
|
||||||
返回值里带上排序后的重要性表。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = fit_forest(X, y)
|
|
||||||
>>> list(r.metrics) # doctest: +SKIP
|
|
||||||
['r2', 'rmse', 'mae']
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def fit_logistic(X: Any, y: Any, C: float = 1.0, random_state: int | None = None, **kwargs: Any) -> Any:
|
|
||||||
"""逻辑回归(二分类)。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
`metrics` 里除了准确率,必须同时给 **混淆矩阵、精确率/召回率、AUC**。
|
|
||||||
只报准确率在不平衡数据上会给出误导性结论。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def kmeans(X: Any, n_clusters: int = 3, standardize_first: bool = True, random_state: int | None = None, **kwargs: Any) -> Any:
|
|
||||||
"""K-means 聚类。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
standardize_first:
|
|
||||||
默认先标准化。**量纲不同的特征不做标准化,K-means 的结果等于随机**。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
`metrics` 给轮廓系数(silhouette)而不是 inertia——
|
|
||||||
轮廓系数有界且可比,inertia 只能同数据集内比。
|
|
||||||
选 `n_clusters` 请画肘部图或轮廓系数曲线,不要拍脑袋。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def pca(X: Any, n_components: int | float | None = None, standardize_first: bool = True) -> Any:
|
|
||||||
"""主成分分析。
|
|
||||||
|
|
||||||
`n_components` 为 `float`(如 0.95)时表示"保留 95% 方差"。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是投影后的得分矩阵,`extras["loadings"]` 是载荷矩阵
|
|
||||||
(论文里解释"第一主成分代表什么"要用它)。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def cross_validate(model: Any, X: Any, y: Any, cv: int = 5, scoring: str | None = None, random_state: int | None = None) -> Any:
|
|
||||||
"""交叉验证,返回每折分数与均值±标准差。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
报结果时**必须带标准差**。只报均值会把"5 折里 1 折崩了"这种事掩盖掉。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""回归(自己封装,底层调用成熟库)。
|
|
||||||
|
|
||||||
对应原建议的 B 类:学的是"怎样把成熟工具变成符合 MCM 工作流的接口",
|
|
||||||
不是重写最小二乘。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = ["fit_linear", "fit_nonlinear", "fit_polynomial", "predict"]
|
|
||||||
|
|
||||||
|
|
||||||
def fit_linear(X: Any, y: Any, intercept: bool = True, weights: Any | None = None, alpha: float = 0.0) -> Any:
|
|
||||||
"""线性回归。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
weights:
|
|
||||||
加权最小二乘的权重(数据点可信度不同、或想消除异方差时用)。
|
|
||||||
alpha:
|
|
||||||
> 0 时做岭回归(共线性严重时用),= 0 是普通最小二乘。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`params={"coef", "intercept"}`,`metrics={"r2", "adjusted_r2", "rmse", "mae"}`。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = fit_linear(X, y)
|
|
||||||
>>> r.metrics["r2"] < 1.0
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
# TODO(L0)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def fit_polynomial(X: Any, y: Any, degree: int = 2, alpha: float = 0.0) -> Any:
|
|
||||||
"""多项式回归。`degree` 大了会过拟合,务必看 `rs.plot_residuals` 和 adjusted_r2。"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def fit_nonlinear(func: Any, X: Any, y: Any, p0: Any = None, bounds: Any = None, **kwargs: Any) -> Any:
|
|
||||||
"""非线性最小二乘拟合(底层 `scipy.optimize.curve_fit`)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
func:
|
|
||||||
`f(x, *params) -> y` 形式的目标函数,例如 logistic / Gompertz 生长曲线。
|
|
||||||
p0:
|
|
||||||
初值。非线性拟合**对初值极度敏感**,给不出好初值时要先画图估一个。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
返回的 `metrics` 里额外给 `pcov`(参数协方差矩阵)与参数标准误,
|
|
||||||
论文里报参数时要带标准误,否则不严谨。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def predict(model: Any, X: Any) -> Any:
|
|
||||||
"""对任意 `fit_*` 返回的 `Result` 或符合协议的模型做预测。
|
|
||||||
|
|
||||||
存在的意义:让 `predict` 既能吃 `Result` 也能吃 sklearn 原生模型,
|
|
||||||
统一比赛代码的写法。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""时间序列(MCM C 必备)。
|
|
||||||
|
|
||||||
原建议列了 `moving_average` `weighted_moving_average` `holt_trend`
|
|
||||||
`seasonal_decompose` `lag_features` `forecast_interval` 等一堆函数,
|
|
||||||
但 statsmodels 已经全部提供,按它自己的原则("做薄封装,不要重写框架")
|
|
||||||
只保留三个真正有封装价值的入口。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = ["exponential_smoothing", "fit_arima", "forecast"]
|
|
||||||
|
|
||||||
|
|
||||||
def exponential_smoothing(series: Any, alpha: float | None = None, trend: str | None = None, seasonal: str | None = None, periods: int | None = None) -> Any:
|
|
||||||
"""指数平滑(底层 `statsmodels.tsa.holtwinters`)。
|
|
||||||
|
|
||||||
`alpha=None` 时自动用极大似然估计平滑系数——比自己拍脑袋定 `alpha` 靠谱。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = exponential_smoothing(y, trend="add") # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def fit_arima(series: Any, order: tuple[int, int, int] = (1, 1, 1), seasonal_order: tuple[int, int, int, int] | None = None, auto: bool = False) -> Any:
|
|
||||||
"""ARIMA 拟合。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
auto:
|
|
||||||
`True` 时按 AIC 网格搜索 (p, d, q),省去手动定阶;
|
|
||||||
数据量大时较慢,比赛里建议先手动看一眼 ACF/PACF。
|
|
||||||
order:
|
|
||||||
`(p, d, q)`——自回归阶数、差分阶数、移动平均阶数。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`metrics={"aic", "bic"}`,`extras["model"]` 是 statsmodels 结果对象
|
|
||||||
(需要 `summary()`、残差诊断时直接拿)。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
ARIMA 要求序列**平稳**。`d` 是差分阶数,但差分会丢信息,
|
|
||||||
非平稳序列更推荐先看是不是有结构突变或季节成分。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def forecast(model: Any, steps: int, alpha: float = 0.05) -> Any:
|
|
||||||
"""向后预测 `steps` 步,并给出预测区间。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是含 `forecast / lower / upper` 三列的 DataFrame。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
**预测区间随步长急剧变宽是正常的**,不要只画中心线——
|
|
||||||
论文里只画一条线而不给区间,是很常见的扣分点。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"""solve —— 求解:优化 / 模拟 / 敏感性 / 图 / 动力学。
|
|
||||||
|
|
||||||
竞赛主力包。原建议把 `optimization` `simulation` `sensitivity` 拆成三个包,
|
|
||||||
但它们本质是同一件事的三种形态——"把模型反复算"——共享
|
|
||||||
`Result`、`rng`、并行与缓存约定。合成一个包,这些约定只写一遍。
|
|
||||||
|
|
||||||
`graph` 与 `dynamics` 是 v0.2 内容(低频,且 NetworkX / SymPy 已覆盖大部分需求)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .optimize import (
|
|
||||||
dynamic_programming,
|
|
||||||
integer_program,
|
|
||||||
knapsack_dp,
|
|
||||||
linear_program,
|
|
||||||
minimize,
|
|
||||||
pareto_front,
|
|
||||||
simulated_annealing,
|
|
||||||
)
|
|
||||||
from .sensitivity import (
|
|
||||||
local_sensitivity,
|
|
||||||
morris_analyze,
|
|
||||||
one_at_a_time,
|
|
||||||
parameter_sweep,
|
|
||||||
robustness_test,
|
|
||||||
sobol_analyze,
|
|
||||||
)
|
|
||||||
from .simulate import (
|
|
||||||
markov_simulate,
|
|
||||||
markov_steady_state,
|
|
||||||
mm1_metrics,
|
|
||||||
monte_carlo,
|
|
||||||
monte_carlo_integral,
|
|
||||||
monte_carlo_probability,
|
|
||||||
random_walk,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"minimize",
|
|
||||||
"linear_program",
|
|
||||||
"integer_program",
|
|
||||||
"dynamic_programming",
|
|
||||||
"knapsack_dp",
|
|
||||||
"simulated_annealing",
|
|
||||||
"pareto_front",
|
|
||||||
"monte_carlo",
|
|
||||||
"monte_carlo_probability",
|
|
||||||
"monte_carlo_integral",
|
|
||||||
"random_walk",
|
|
||||||
"markov_simulate",
|
|
||||||
"markov_steady_state",
|
|
||||||
"mm1_metrics",
|
|
||||||
"local_sensitivity",
|
|
||||||
"one_at_a_time",
|
|
||||||
"parameter_sweep",
|
|
||||||
"robustness_test",
|
|
||||||
"sobol_analyze",
|
|
||||||
"morris_analyze",
|
|
||||||
]
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""微分方程与动力系统(v0.2)。
|
|
||||||
|
|
||||||
原建议把 `dynamics/` 做成独立包。考虑到:
|
|
||||||
1. 只有 MCM A 用得上,一年一题;
|
|
||||||
2. 底层 SymPy(解析)+ SciPy(数值)已经覆盖全部需求;
|
|
||||||
|
|
||||||
降级为一个模块。依赖:`pip install "mcm-toolkit[symbolic]"`(sympy)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"find_equilibrium",
|
|
||||||
"local_stability",
|
|
||||||
"solve_ode",
|
|
||||||
"solve_system",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def solve_ode(
|
|
||||||
derivative: Callable[..., Any],
|
|
||||||
t_span: tuple[float, float],
|
|
||||||
y0: Any,
|
|
||||||
t_eval: Any = None,
|
|
||||||
method: str = "RK45",
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> Any:
|
|
||||||
"""解 ODE 初值问题(底层 `scipy.integrate.solve_ivp`)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
method:
|
|
||||||
`"RK45"` 通用;`"LSODA"` 刚性系统自动切换(种群/化学反应常见);
|
|
||||||
`"Radau"` / `"BDF"` 强刚性。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
**刚性是 ODE 最常踩的坑**:非刚性方法遇到刚性问题会跑到天荒地老
|
|
||||||
且以为是自己代码慢。判据:解里同时存在快慢两个时间尺度。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = solve_ode(lambda t, y: -y, t_span=(0, 5), y0=[1.0]) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(v0.2)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def solve_system(equations: Any, variables: Any, **kwargs: Any) -> Any:
|
|
||||||
"""解方程组(底层 `sympy.solve` / `scipy.optimize.root`)。
|
|
||||||
|
|
||||||
`equations` 是符号表达式列表时走 SymPy 解析解,是数值函数时走 root 数值解。
|
|
||||||
"""
|
|
||||||
# TODO(v0.2)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def find_equilibrium(f: Callable[..., Any], x0: Any, n_starts: int = 10, random_state: int | None = None) -> Any:
|
|
||||||
"""找平衡点。
|
|
||||||
|
|
||||||
**必须多初值搜索**:单个初值只能找到最近的平衡点,
|
|
||||||
而"系统有几个平衡点、分别是什么类型"才是题目真正要问的。
|
|
||||||
"""
|
|
||||||
# TODO(v0.2)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def local_stability(jacobian_matrix: Any, equilibrium: Any = None) -> Any:
|
|
||||||
"""按 Jacobian 的特征值判定平衡点的局部稳定性。
|
|
||||||
|
|
||||||
判据(连续系统):实部全负 → 稳定结点/焦点;有正实部 → 不稳定;
|
|
||||||
纯虚根 → 中心(临界情形,线性化失效,需要非线性分析)。
|
|
||||||
`equilibrium` 用于把特征值和解的位置一起报出来。
|
|
||||||
"""
|
|
||||||
# TODO(v0.2)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""图与网络(v0.2)。
|
|
||||||
|
|
||||||
原建议把 `network/` 做成一个独立包(5 个子模块、20+ 函数),
|
|
||||||
但里面绝大多数是 NetworkX 的一行转发,且只有 ICM D 用得上。
|
|
||||||
降级为一个模块,只留真正有封装价值的入口。
|
|
||||||
|
|
||||||
依赖:`pip install "mcm-toolkit[graph]"`(networkx)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"betweenness_centrality",
|
|
||||||
"build_graph",
|
|
||||||
"community_detection",
|
|
||||||
"max_flow",
|
|
||||||
"min_spanning_tree",
|
|
||||||
"pagerank",
|
|
||||||
"shortest_path",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_graph(edges: Any, directed: bool = False, weighted: bool = True) -> Any:
|
|
||||||
"""从边表建图。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
edges:
|
|
||||||
`[(u, v), ...]` 或 `[(u, v, w), ...]`,也可以是 DataFrame(列名 `source/target/weight`)。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> G = build_graph([("A", "B"), ("B", "C")]) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(v0.2)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def shortest_path(G: Any, source: Any, target: Any = None, weight: str | None = None) -> Any:
|
|
||||||
"""最短路径。`target=None` 时返回从 source 出发到所有点的路径。"""
|
|
||||||
# TODO(v0.2)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def min_spanning_tree(G: Any, weight: str = "weight") -> Any:
|
|
||||||
"""最小生成树(铺管网、修路这类"连通成本最小"题目)。"""
|
|
||||||
# TODO(v0.2)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def betweenness_centrality(G: Any, normalized: bool = True) -> Any:
|
|
||||||
"""介数中心性。
|
|
||||||
|
|
||||||
节点的"关键程度"有三种主流度量(度/接近/介数),
|
|
||||||
论文里选哪一个要说明理由:**介数**衡量"必经之路",
|
|
||||||
**度**衡量"连接多",**特征向量**衡量"连到重要节点"。
|
|
||||||
原建议把四个都列了,这里只留最常用的介数,其余直接调 networkx。
|
|
||||||
"""
|
|
||||||
# TODO(v0.2)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def pagerank(G: Any, alpha: float = 0.85, max_iter: int = 100) -> Any:
|
|
||||||
"""PageRank(有向图的节点重要性)。"""
|
|
||||||
# TODO(v0.2)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def max_flow(G: Any, source: Any, sink: Any, capacity: str = "capacity") -> Any:
|
|
||||||
"""最大流 / 最小割(网络容量、瓶颈分析)。"""
|
|
||||||
# TODO(v0.2)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def community_detection(G: Any, method: str = "louvain", random_state: int | None = None) -> Any:
|
|
||||||
"""社区发现。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是 `{节点: 社区编号}`,`metrics={"modularity"}`。
|
|
||||||
**必须报模块度 Q**,否则社区划分好不好无从判断。
|
|
||||||
"""
|
|
||||||
# TODO(v0.2)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
"""优化。
|
|
||||||
|
|
||||||
分层(对应原建议的 A/B/C 三级):
|
|
||||||
|
|
||||||
* **B 类(薄封装)**:`minimize` `linear_program` `integer_program` —— 底层 SciPy。
|
|
||||||
* **A 类(自己实现,S 档)**:`dynamic_programming` `knapsack_dp` `simulated_annealing`。
|
|
||||||
* **C 类(直接用库)**:NSGA-II 等,用 pymoo,本模块只提供 Pareto 前沿的**后处理**。
|
|
||||||
|
|
||||||
原建议里的 `maximize` 已删:它就是 `minimize` 取负,用 `sense="max"` 参数表达。
|
|
||||||
`value_iteration` / `policy_iteration` 也已删:与 `dynamic_programming` 同族,
|
|
||||||
第一版同时做三个属于过度设计。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"dynamic_programming",
|
|
||||||
"integer_program",
|
|
||||||
"knapsack_dp",
|
|
||||||
"linear_program",
|
|
||||||
"minimize",
|
|
||||||
"pareto_front",
|
|
||||||
"simulated_annealing",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def minimize(
|
|
||||||
objective: Callable[..., float],
|
|
||||||
x0: Any = None,
|
|
||||||
bounds: Any = None,
|
|
||||||
constraints: Any = None,
|
|
||||||
method: str = "auto",
|
|
||||||
sense: str = "min",
|
|
||||||
random_state: int | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> Any:
|
|
||||||
"""连续优化(底层 `scipy.optimize.minimize`)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
method:
|
|
||||||
`"auto"` 时按"有无约束 / 有无梯度"自动选:
|
|
||||||
无约束 → BFGS;有边界 → L-BFGS-B;有约束 → SLSQP。
|
|
||||||
sense:
|
|
||||||
`"min"` / `"max"`。取 max 时内部对目标函数取负并还原结果,
|
|
||||||
调用方不用自己写负号(负号写错是这类题最常见的手滑)。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`params={"x": 最优解}`,`metrics={"objective": 目标值}`。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
**结果必须在论文里报"是否收敛"和"初值敏感性"**:
|
|
||||||
用一个 `x0` 跑出来的局部最优,和换 10 个随机初值跑出来的全局最优,
|
|
||||||
结论可能完全不同。返回值里带上 `success` 与 `message` 就是为了这个。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> f = lambda v: (v[0] - 3) ** 2 + (v[1] + 1) ** 2
|
|
||||||
>>> r = minimize(f, x0=[0, 0])
|
|
||||||
>>> round(r.metrics["objective"], 6)
|
|
||||||
0.0
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def linear_program(
|
|
||||||
c: Any,
|
|
||||||
A_ub: Any = None,
|
|
||||||
b_ub: Any = None,
|
|
||||||
A_eq: Any = None,
|
|
||||||
b_eq: Any = None,
|
|
||||||
bounds: Any = None,
|
|
||||||
sense: str = "min",
|
|
||||||
) -> Any:
|
|
||||||
"""线性规划(底层 `scipy.optimize.linprog`)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
c:
|
|
||||||
目标函数系数。`sense="max"` 时内部取负。
|
|
||||||
bounds:
|
|
||||||
`None` 表示默认 `x >= 0`;要无下界显式写 `(None, None)`。
|
|
||||||
**这是最容易踩的坑**:忘记声明变量非负,解出来的方案可能物理上不可行。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = linear_program(c=[1, 2], A_ub=[[1, 1]], b_ub=[4], bounds=[(0, None)] * 2)
|
|
||||||
>>> r.metrics["objective"]
|
|
||||||
4.0
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def integer_program(
|
|
||||||
c: Any,
|
|
||||||
integrality: Any,
|
|
||||||
A_ub: Any = None,
|
|
||||||
b_ub: Any = None,
|
|
||||||
A_eq: Any = None,
|
|
||||||
b_eq: Any = None,
|
|
||||||
bounds: Any = None,
|
|
||||||
sense: str = "min",
|
|
||||||
) -> Any:
|
|
||||||
"""整数规划(底层 `scipy.optimize.milp`)。
|
|
||||||
|
|
||||||
`integrality`:0 = 连续,1 = 整数,2 = 0-1 变量。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
整数规划比 LP 慢得多。变量数到几百以上时先想想能不能用
|
|
||||||
线性松弛 + 取整 + 邻域搜索近似,别硬等求解器。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def dynamic_programming(
|
|
||||||
states: Any,
|
|
||||||
actions: Any,
|
|
||||||
transition: Callable[..., Any],
|
|
||||||
reward: Callable[..., float],
|
|
||||||
horizon: int,
|
|
||||||
discount: float = 1.0,
|
|
||||||
) -> Any:
|
|
||||||
"""通用有限期动态规划(A 类,自己实现)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
transition:
|
|
||||||
`f(state, action, rng) -> next_state`。
|
|
||||||
reward:
|
|
||||||
`f(state, action, next_state) -> float`。
|
|
||||||
horizon:
|
|
||||||
期数。`discount < 1` 时按 `γ^t` 折算。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是"最优值函数表 + 最优策略表",`extras["policy"]` 可直接查。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
复杂度 `O(horizon × |S| × |A|)`。**状态空间是唯一瓶颈**——
|
|
||||||
如果 `|S| × holiday` 超过内存,要么精简状态离散化粒度,要么改成近似 DP。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> # 背包问题用 knapsack_dp 更直接;本函数用于"逐期决策"类题
|
|
||||||
>>> r = dynamic_programming(states, actions, trans, rew, horizon=10) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L1) ★ S 档:配完整函数卡片
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def knapsack_dp(weights: Any, values: Any, capacity: float) -> Any:
|
|
||||||
"""0-1 背包(动态规划模板,A 类,自己实现)。
|
|
||||||
|
|
||||||
复杂度 `O(n × capacity)`。`capacity` 在题面里是小数时先做单位缩放取整,
|
|
||||||
否则 DP 表会大到装不下。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`params={"selected": 选中的下标}`,`metrics={"total_value", "total_weight"}`。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = knapsack_dp(weights=[1, 3, 4, 5], values=[1, 4, 5, 7], capacity=7)
|
|
||||||
>>> r.metrics["total_value"]
|
|
||||||
9.0
|
|
||||||
"""
|
|
||||||
# TODO(L1) ★ S 档
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def simulated_annealing(
|
|
||||||
objective: Callable[..., float],
|
|
||||||
x0: Any,
|
|
||||||
bounds: Any = None,
|
|
||||||
temp0: float = 1.0,
|
|
||||||
cooling: float = 0.95,
|
|
||||||
n_iter: int = 1000,
|
|
||||||
sense: str = "min",
|
|
||||||
random_state: int | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> Any:
|
|
||||||
"""模拟退火(A 类,自己实现)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
cooling:
|
|
||||||
降温系数,每步 `T *= cooling`。`0.95` 配 `n_iter=1000` 是保守默认值。
|
|
||||||
temp0:
|
|
||||||
初始温度。**经验做法:取"随机扰动的典型劣化幅度"同量级**,
|
|
||||||
太大等于随机游走,太小等于爬山。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`extras["history"]` 是每步的最优值轨迹,可直接喂给 `viz.plot_convergence`。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
* 必须记录收敛曲线并画出来——论文里"算法收敛性分析"那一节就靠它。
|
|
||||||
* 结果对随机种子敏感,**至少跑 10 个种子**,报最好值 + 分布,
|
|
||||||
只报一个种子的结果是不可信的。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = simulated_annealing(lambda v: sum(x * x for x in v), x0=[3.0, 4.0], random_state=0) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L1) ★ S 档:配完整函数卡片
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def pareto_front(points: Any, sense: str = "min") -> Any:
|
|
||||||
"""从解集里筛出 Pareto 非支配解。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`params={"mask": 布尔掩码, "rank": 每个解的支配层}`。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
多目标问题的**后处理**入口。真正的 NSGA-II 求解器用 pymoo
|
|
||||||
(`pip install "mcm-toolkit[moo]"`),本函数只负责"给定一组解,挑出前沿"。
|
|
||||||
它也是画 Pareto 前沿图的必要前置步骤。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
"""敏感性分析与鲁棒性检验。
|
|
||||||
|
|
||||||
★ 建议做成招牌模块。原建议这一节方向完全正确,只是函数名偏多,
|
|
||||||
已合并同义项、删掉 `grid_sweep`(`parameter_sweep` 的特例)。
|
|
||||||
|
|
||||||
论文里的"敏感性分析"一节基本就是这四个函数的组合:
|
|
||||||
局部扰动 → OAT 单因素扫描 → 多因素参数扫描 → 全局 Sobol/Morris。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"local_sensitivity",
|
|
||||||
"one_at_a_time",
|
|
||||||
"parameter_sweep",
|
|
||||||
"robustness_test",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def local_sensitivity(
|
|
||||||
model: Callable[..., Any],
|
|
||||||
params: Any,
|
|
||||||
step: float = 1e-4,
|
|
||||||
relative: bool = True,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> Any:
|
|
||||||
"""局部敏感性:各参数的偏导 / 弹性。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
relative:
|
|
||||||
`True` 时算弹性 `(∂y/∂p)·(p/y)`——**无量纲,可以跨参数比较**,
|
|
||||||
这是局部敏感性的正确做法;`False` 时是原始偏导(有量纲,只有同量纲参数之间可比)。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是参数 → 弹性 的表,按绝对值降序。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
有限差分的步长是精度与截断误差的权衡:太大截断误差大,
|
|
||||||
太小被浮点误差淹没。`1e-4` 是稳健默认值;参数本身量级差很多时用相对步长。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> s = local_sensitivity(lambda p: p["a"] ** 2 * p["b"], {"a": 2.0, "b": 3.0}) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L1) ★ S 档
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def one_at_a_time(
|
|
||||||
model: Callable[..., Any],
|
|
||||||
params: Any,
|
|
||||||
parameter: str | None = None,
|
|
||||||
values: Any = None,
|
|
||||||
n_points: int = 20,
|
|
||||||
span: float = 0.5,
|
|
||||||
) -> Any:
|
|
||||||
"""OAT(一次只动一个参数)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
parameter:
|
|
||||||
`None` 时对所有参数依次扫描。
|
|
||||||
span:
|
|
||||||
相对基准值的扫描范围,`0.5` 表示 ±50%。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是长表 `(parameter, value, output)`,配 `viz.plot` 直接出蛛网图/敏感性曲线。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
OAT 的**已知缺陷**:它看不见参数之间的交互作用。
|
|
||||||
如果两个参数是相乘关系(很常见),OAT 会严重低估整体敏感性——
|
|
||||||
论文里必须说明这一点,并补一个全局敏感性作为交叉验证。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def parameter_sweep(
|
|
||||||
model: Callable[..., Any],
|
|
||||||
ranges: dict[str, Any],
|
|
||||||
n_points: int = 20,
|
|
||||||
method: str = "grid",
|
|
||||||
n_samples: int = 1000,
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""多参数扫描(合并了原建议的 `grid_sweep` 与 `random_parameter_sweep`)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
ranges:
|
|
||||||
`{"alpha": (0.1, 0.9), "beta": (0, 5)}`。
|
|
||||||
method:
|
|
||||||
`"grid"`:笛卡尔积,点数 = `n_points^d`,**维度一高就爆**(d=5, n=20 是 320 万次);
|
|
||||||
`"lhs"`:拉丁超立方,维度高时用这个;
|
|
||||||
`"random"`:纯随机。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是 `(n_points^d, d + 1)` 的结果表,
|
|
||||||
`params["best"]` 是让目标最优的参数组合。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
`d >= 4` 时默认改走 `"lhs"`,否则 `n_points` 会被指数放大到跑不完。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = parameter_sweep(lambda a, b: -(a - 0.3) ** 2 - (b - 2) ** 2,
|
|
||||||
... {"a": (0, 1), "b": (0, 4)}, n_points=11) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L0)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def robustness_test(
|
|
||||||
model: Callable[..., Any],
|
|
||||||
scenarios: Any = None,
|
|
||||||
params: Any = None,
|
|
||||||
perturbation: float = 0.1,
|
|
||||||
n_samples: int = 1000,
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""鲁棒性检验(合并了原建议的 `perturbation_test`)。
|
|
||||||
|
|
||||||
在参数上施加 ±`perturbation` 的随机扰动,看结论是否翻盘。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`metrics` 含 `"rank_stability"`(原结论在前 X% 的比例)、
|
|
||||||
`"conclusion_flip_rate"`(结论反转频率)。**这两个数字才是审稿人想看的。**
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
鲁棒性检验和敏感性分析不是一回事:
|
|
||||||
敏感性问"参数变了输出变多少",鲁棒性问"参数变了**结论**还成立吗"。
|
|
||||||
论文里两节都要有,别混着写。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = robustness_test(model, params={"alpha": 0.5}, random_state=0) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def sobol_analyze(
|
|
||||||
model: Callable[..., Any],
|
|
||||||
bounds: Any,
|
|
||||||
n_samples: int = 1024,
|
|
||||||
calc_second_order: bool = False,
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""Sobol 全局敏感性(底层 SALib,自己不要重写)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
bounds:
|
|
||||||
`{"alpha": (0, 1), "beta": (0, 5)}` 形式的参数上下界。
|
|
||||||
n_samples:
|
|
||||||
SALib 的基样本数 N;**实际模型调用次数是 `N·(2d + 2)`**
|
|
||||||
(二阶时更多),d=5、N=1024 就是 12288 次调用。先估一下模型单次耗时。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 含 `S1`(一阶)、`ST`(总效应)、`S2`(二阶,可选)三列。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
* `ST - S1` 的差距就是**交互作用**的大小——这正是 OAT 看不见的东西。
|
|
||||||
两者差得多,说明不能只看单因素。
|
|
||||||
* 需要 `pip install "mcm-toolkit[sensitivity]"`;缺 SALib 时本函数给出
|
|
||||||
可读的安装提示,而不是裸抛 `ModuleNotFoundError`。
|
|
||||||
* 费用高:**先做 OAT 缩小参数范围,再对少数关键参数做 Sobol**,
|
|
||||||
不要一上来就对全部参数做全局敏感性。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = sobol_analyze(model, bounds={"a": (0, 1), "b": (0, 5)}) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L1) ★ S 档:配完整函数卡片
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def morris_analyze(
|
|
||||||
model: Callable[..., Any],
|
|
||||||
bounds: Any,
|
|
||||||
n_trajectories: int = 10,
|
|
||||||
n_levels: int = 4,
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""Morris 筛选法(底层 SALib)。
|
|
||||||
|
|
||||||
调用次数只与轨迹数成正比(`(d + 1) · n_trajectories`),比 Sobol 便宜一个量级,
|
|
||||||
适合**参数多但只需要找出"哪几个重要"**的筛选阶段。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 含 `mu`、`mu_star`(推荐看的)、`sigma` 三列。
|
|
||||||
`sigma` 大表示该参数的效应是非线性的或与其他参数有交互。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = morris_analyze(model, bounds={"a": (0, 1), "b": (0, 5)}) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,800 +0,0 @@
|
|||||||
"""随机模拟:Monte Carlo / 马尔可夫 / 随机游走 / 排队。
|
|
||||||
|
|
||||||
★ 本模块的 `monte_carlo` 与 `monte_carlo_integral` 是 S 档,建议自己实现。
|
|
||||||
原建议里的 `brownian_motion` `geometric_brownian_motion` 已删(niche),
|
|
||||||
`simulate_queue` 也已删(等于自己写一个离散事件仿真引擎,成本远高于收益)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from scipy.stats import norm
|
|
||||||
|
|
||||||
from mcm_toolkit.core import Result, as_array, get_rng
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"markov_simulate",
|
|
||||||
"markov_steady_state",
|
|
||||||
"mm1_metrics",
|
|
||||||
"monte_carlo",
|
|
||||||
"monte_carlo_integral",
|
|
||||||
"monte_carlo_probability",
|
|
||||||
"random_walk",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def monte_carlo(
|
|
||||||
sampler: Callable[..., Any],
|
|
||||||
simulator: Callable[..., Any],
|
|
||||||
n: int | np.integer = 10000,
|
|
||||||
random_state: int | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> Any:
|
|
||||||
"""通用蒙特卡洛:采样 → 仿真 → 汇总。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
sampler:
|
|
||||||
`f(rng, size, **kwargs) -> 一批参数`。**一次就要抽够 `n` 组**,
|
|
||||||
例如 `lambda rng, size: rng.normal(0, 1, size)`。
|
|
||||||
批量大小由本函数以 `size=n` 传进去:`sampler` 必须能接住 `size`
|
|
||||||
(显式声明,或带 `**kwargs`)。**别再往 `**kwargs` 里塞 `size`**,否则重复传参。
|
|
||||||
simulator:
|
|
||||||
`f(params) -> 一批结果`。收到 `sampler` 的那一整批参数,
|
|
||||||
返回长度同为 `n` 的数组(标量 / 长度对不上都算契约错误)。
|
|
||||||
**内部不要写 Python 循环**。
|
|
||||||
n:
|
|
||||||
模拟次数,正整数。与 `simulator` 返回的长度必须一致。
|
|
||||||
random_state:
|
|
||||||
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
|
|
||||||
**kwargs:
|
|
||||||
原样转交给 `sampler`,例如分布参数 `mu` / `sigma`(`size` 由本函数自己传,见上)。
|
|
||||||
会一并记进 `Result.params`,便于复现。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是 `n` 个结果组成的数组(一律转成 float,布尔结果按 0/1 统计),
|
|
||||||
`metrics={"mean", "std", "median", "p05", "p95", "ci_low", "ci_high"}`。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError:
|
|
||||||
`sampler` / `simulator` 不可调用,`n` 不是整数,或 `random_state` 类型非法。
|
|
||||||
ValueError:
|
|
||||||
`n` 不是正整数。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
比赛里 Monte Carlo 的收敛速度是 `O(1/√n)` —— 想少一半误差就要 4 倍样本。
|
|
||||||
所以论文里要给"区间随 n 收敛"的图,证明 `n` 已经够大
|
|
||||||
(这也是 `viz.plot_convergence` 的典型用法)。
|
|
||||||
|
|
||||||
结果里只要出现 **NaN** 就判失败(`success=False`):MC 的 NaN 通常来自除零、
|
|
||||||
取负数的根号或模型发散,静默统计只会得到一列 nan 指标。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> draw = lambda rng, size, **k: rng.normal(0, 1, size)
|
|
||||||
>>> r = monte_carlo(draw, lambda x: x ** 2, n=10000, random_state=0)
|
|
||||||
>>> bool(abs(r.metrics["mean"] - 1.0) < 0.1) # E[X²] = 1
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
if not callable(sampler) or not callable(simulator):
|
|
||||||
raise TypeError("sampler 和 simulator 必须是可调用函数")
|
|
||||||
if isinstance(n, bool) or not isinstance(n, (int, np.integer)):
|
|
||||||
raise TypeError(f"n 必须是整数,你传入了 {type(n).__name__}")
|
|
||||||
if int(n) <= 0:
|
|
||||||
raise ValueError(f"n 必须是正整数,你传入了 {n}")
|
|
||||||
n = int(n)
|
|
||||||
|
|
||||||
rng = get_rng(random_state)
|
|
||||||
call_params = {"n": n, "random_state": random_state, **kwargs}
|
|
||||||
|
|
||||||
try:
|
|
||||||
params = sampler(rng, size=n, **kwargs)
|
|
||||||
data = np.asarray(simulator(params), dtype=float).ravel()
|
|
||||||
if np.isnan(data).any():
|
|
||||||
raise ValueError("模拟结果中包含 NaN,请检查 sampler 或 simulator 的传入参数")
|
|
||||||
if len(data) != n:
|
|
||||||
raise ValueError(f"simulator 返回的结果数量({len(data)})必须与模拟次数 n({n})一致")
|
|
||||||
|
|
||||||
metrics = {
|
|
||||||
"mean": float(np.mean(data)),
|
|
||||||
"std": float(np.std(data, ddof=0)),
|
|
||||||
"median": float(np.median(data)),
|
|
||||||
"p05": float(np.percentile(data, 5)),
|
|
||||||
"p95": float(np.percentile(data, 95)),
|
|
||||||
"ci_low": float(np.percentile(data, 2.5)),
|
|
||||||
"ci_high": float(np.percentile(data, 97.5)),
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result(
|
|
||||||
name="monte_carlo",
|
|
||||||
params=call_params,
|
|
||||||
metrics=metrics,
|
|
||||||
data=data,
|
|
||||||
success=True,
|
|
||||||
message=f"蒙特卡洛模拟成功:已完成 {n} 次模拟",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return Result(
|
|
||||||
name="monte_carlo",
|
|
||||||
params=call_params,
|
|
||||||
metrics={},
|
|
||||||
data=None,
|
|
||||||
success=False,
|
|
||||||
message=f"蒙特卡洛模拟失败:{type(e).__name__} - {e}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def monte_carlo_probability(
|
|
||||||
event: Callable[..., Any],
|
|
||||||
sampler: Callable[..., Any],
|
|
||||||
n: int | np.integer = 10000,
|
|
||||||
confidence: float = 0.95,
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""估计事件发生概率,并给出区间。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
event:
|
|
||||||
`f(params) -> 一批 bool`。吃到 `sampler` 的**整批**参数,
|
|
||||||
返回长度为 `n` 的布尔数组(写成 `z > 0` 这种逐元素判断即可,不要写 Python 循环)。
|
|
||||||
sampler:
|
|
||||||
`f(rng, size, **kwargs) -> 一批参数`,契约同 `monte_carlo`:
|
|
||||||
批量大小由本函数以 `size=n` 传入,`sampler` 必须能接住 `size`。
|
|
||||||
n:
|
|
||||||
模拟次数,正整数。与 `event` 返回的长度必须一致。
|
|
||||||
confidence:
|
|
||||||
区间置信水平,必须落在开区间 `(0, 1)` 内。
|
|
||||||
random_state:
|
|
||||||
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是 `n` 个 0/1 结果组成的数组,
|
|
||||||
`metrics={"probability", "ci_low", "ci_high", "std_error"}`。
|
|
||||||
区间是 Wilson score 区间(`p` 贴近 0 / 1 时比 Wald 稳),并夹在 `[0, 1]` 内(比例量纲,见决策 42)。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError:
|
|
||||||
`n` 不是整数,或 `random_state` 类型非法。
|
|
||||||
ValueError:
|
|
||||||
`n` 不是正整数,或 `confidence` 不在 `(0, 1)` 内。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
概率估计的相对误差 ≈ `1/√(n·p)`。**小概率事件(p < 1e-3)用朴素 MC 是不可行的**,
|
|
||||||
需要重要性采样或方差缩减——遇到这类题要提前意识到,别硬跑。
|
|
||||||
本函数当前的口径:`probability <= 1e-3` 时**直接判 `success=False`**(只给提示,不给区间)。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> draw = lambda rng, size, **k: rng.normal(0, 1, size)
|
|
||||||
>>> r = monte_carlo_probability(lambda z: z > 0, draw, n=2000, random_state=0)
|
|
||||||
>>> abs(r.metrics["probability"] - 0.5) < 0.1
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
if not callable(sampler) or not callable(event):
|
|
||||||
raise TypeError("sampler 和 event 必须是可调用函数")
|
|
||||||
if isinstance(n, bool) or not isinstance(n, (int, np.integer)):
|
|
||||||
raise TypeError(f"n 必须是整数,你传入了 {type(n).__name__}")
|
|
||||||
if int(n) <= 0:
|
|
||||||
raise ValueError(f"n 必须是正整数,你传入了 {n}")
|
|
||||||
n = int(n)
|
|
||||||
if confidence <= 0.0 or confidence >= 1:
|
|
||||||
raise ValueError(f"confidence必须在0和1之间,而你输入了{confidence}")
|
|
||||||
|
|
||||||
rng = get_rng(random_state)
|
|
||||||
call_params = {"n": n, "random_state": random_state, "confidence": confidence}
|
|
||||||
|
|
||||||
try:
|
|
||||||
params = sampler(rng, size=n)
|
|
||||||
data = np.asarray(event(params), dtype=float).ravel()
|
|
||||||
if np.isnan(data).any():
|
|
||||||
raise ValueError("模拟结果中包含 NaN,请检查 event 或 sampler 的传入参数")
|
|
||||||
if len(data) != n:
|
|
||||||
raise ValueError(f"event 返回的结果数量({len(data)})必须与模拟次数 n({n})一致")
|
|
||||||
p_value = float(np.mean(data))
|
|
||||||
std_error = float(np.sqrt(p_value * (1.0 - p_value) / n))
|
|
||||||
|
|
||||||
z_score = norm.ppf(1 - (1 - confidence) / 2)
|
|
||||||
center = (p_value + z_score**2 / (2 * n)) / (1 + z_score ** 2 / n)
|
|
||||||
half_width = (z_score / (1 + z_score ** 2 / n)) * np.sqrt(p_value * (1 - p_value) / n + z_score ** 2 / (4 * n ** 2))
|
|
||||||
ci_low = max(0.0, center - half_width)
|
|
||||||
ci_high = min(1.0, center + half_width)
|
|
||||||
|
|
||||||
warning_message = ""
|
|
||||||
if p_value <= 1e-3 or p_value >= 1 - 1e-3:
|
|
||||||
warning_message = "小概率事件 P < 1e-3 或者高概率事件 P > 1 - 1e-3,不建议使用monte_carlo_probability"
|
|
||||||
|
|
||||||
metrics = {
|
|
||||||
"probability": p_value,
|
|
||||||
"ci_low": float(ci_low),
|
|
||||||
"ci_high": float(ci_high),
|
|
||||||
"std_error": std_error,
|
|
||||||
}
|
|
||||||
return Result(
|
|
||||||
name="monte_carlo_probability",
|
|
||||||
params=call_params,
|
|
||||||
metrics=metrics,
|
|
||||||
data=data,
|
|
||||||
success=True,
|
|
||||||
message=(
|
|
||||||
f"蒙特卡洛模拟成功:已完成 {n} 次模拟"
|
|
||||||
+ (f";{warning_message}" if warning_message else "")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return Result(
|
|
||||||
name="monte_carlo_probability",
|
|
||||||
params=call_params,
|
|
||||||
metrics={},
|
|
||||||
data=None,
|
|
||||||
success=False,
|
|
||||||
message=f"蒙特卡洛模拟失败:{type(e).__name__} - {e}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def monte_carlo_integral(
|
|
||||||
func: Callable[..., Any],
|
|
||||||
bounds: Any,
|
|
||||||
n: int | np.integer = 10000,
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""用蒙特卡洛求多重积分。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
func:
|
|
||||||
`f(X) -> 一批函数值`。`X` 是形状 `(n, d)` 的样本矩阵(一行一个样本),
|
|
||||||
返回长度 `n` 的数组(写成 `X[:, 0] ** 2` 这种逐行写法,不要写 Python 循环)。
|
|
||||||
bounds:
|
|
||||||
`[(lo, hi), ...]`(非空 list / tuple),维度即积分重数;要求每项都是二元组且 `lo < hi`。
|
|
||||||
n:
|
|
||||||
模拟次数,正整数。
|
|
||||||
random_state:
|
|
||||||
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:**`metrics["mean"]` 才是积分估计值** ≈ `∫f dV`;
|
|
||||||
`data` 是 `n` 个样本各自的贡献(`func(X_i) × 体积`),可拿去画随 `n` 的收敛图。
|
|
||||||
`params` 里额外记了 `bounds` 与 `dimension`。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError:
|
|
||||||
`bounds` 不是非空 list / tuple、形状不是 `(d, 2)`(例如漏括号写成 `[0, 1]`),或某个 `lo >= hi`。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
高维积分是 MC 相对网格法的**唯一绝对优势**:网格法误差 `O(h^{-d})` 随维度爆炸,
|
|
||||||
MC 始终是 `O(1/√n)`。三维以上优先考虑 MC。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> r = monte_carlo_integral(lambda X: X[:, 0] ** 2, bounds=[(0, 1)], n=200000, random_state=0)
|
|
||||||
>>> abs(r.metrics["mean"] - 1 / 3) < 0.01 # ∫₀¹ x² dx = 1/3
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
if not isinstance(bounds, np.ndarray):
|
|
||||||
if not isinstance(bounds, (list, tuple)) or len(bounds) == 0:
|
|
||||||
raise ValueError("bounds 必须是非空的列表或元组")
|
|
||||||
|
|
||||||
bounds_arr = np.asarray(bounds)
|
|
||||||
else:
|
|
||||||
bounds_arr = bounds.copy()
|
|
||||||
|
|
||||||
if bounds_arr.ndim != 2 or bounds_arr.shape[1] != 2:
|
|
||||||
raise ValueError(f"bounds 必须形如 [(lo, hi), ...],当前形状为 {bounds_arr.shape},请检查是否漏了括号")
|
|
||||||
|
|
||||||
lows = bounds_arr[:, 0]
|
|
||||||
highs = bounds_arr[:, 1]
|
|
||||||
d = len(lows)
|
|
||||||
if np.any(lows >= highs):
|
|
||||||
raise ValueError("积分边界非法:下界必须严格小于上界")
|
|
||||||
|
|
||||||
widths = highs - lows
|
|
||||||
|
|
||||||
volume = np.prod(widths)
|
|
||||||
|
|
||||||
def sampler(rng, size=n):
|
|
||||||
return rng.uniform(low=lows, high=highs, size=(size, d))
|
|
||||||
|
|
||||||
def simulator(X):
|
|
||||||
vals = func(X)
|
|
||||||
if len(vals) != n:
|
|
||||||
raise ValueError(f"func 返回的结果数量({len(vals)})必须与模拟次数 n({n})一致")
|
|
||||||
return np.asarray(vals, dtype=float).ravel() * volume
|
|
||||||
|
|
||||||
result = monte_carlo(
|
|
||||||
sampler=sampler,
|
|
||||||
simulator=simulator,
|
|
||||||
n=n,
|
|
||||||
random_state=random_state,
|
|
||||||
)
|
|
||||||
|
|
||||||
result.name = "monte_carlo_integral"
|
|
||||||
result.params["bounds"] = bounds
|
|
||||||
result.params["dimension"] = d
|
|
||||||
if result.success:
|
|
||||||
result.message = f"蒙特卡洛多重积分成功:维度 {d},体积 {volume:.4f},模拟 {n} 次。"
|
|
||||||
else:
|
|
||||||
result.message = f"蒙特卡洛多重积分失败:维度 {d},体积 {volume:.4f},模拟 {n} 次。{result.message}"
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def random_walk(
|
|
||||||
n_steps: int | np.integer,
|
|
||||||
n_paths: int | np.integer = 1,
|
|
||||||
step_sampler: Callable[..., Any] | None = None,
|
|
||||||
start: float = 0.0,
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""随机游走轨迹:一次抽完整批增量,再 `cumsum` 成路径。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
n_steps:
|
|
||||||
每条路径的步数,正整数。`data` 的列数是 `n_steps + 1`(多出的那一列是起点)。
|
|
||||||
n_paths:
|
|
||||||
路径条数,正整数,默认 1 条。
|
|
||||||
step_sampler:
|
|
||||||
`f(rng, size) -> 一批增量`。**`size` 是整数** `n_paths * n_steps`(与 `monte_carlo` 的 `sampler` 同一契约),
|
|
||||||
必须返回**一维**长度 `n_paths * n_steps` 的数组;本函数负责 `reshape(n_paths, n_steps)`(行主序)。
|
|
||||||
返回二维一律判失败 —— 否则"转置的采样器"在 `n_paths == n_steps` 时会静默给出转置结果。
|
|
||||||
默认 ±1 等概率(对称随机游走)。
|
|
||||||
start:
|
|
||||||
起点,写进每条路径的第 0 列。
|
|
||||||
random_state:
|
|
||||||
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 形状 `(n_paths, n_steps + 1)`,第 0 列恒为 `start`,之后是逐列累加出来的位置;
|
|
||||||
`metrics={"mean_terminal", "std_terminal"}`(终点的均值与标准差,`std` 用 `ddof=0`)。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError:
|
|
||||||
`n_steps` / `n_paths` 不是整数。
|
|
||||||
`start` 不是数值。
|
|
||||||
ValueError:
|
|
||||||
`n_steps` / `n_paths` 不是正整数。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
随机游走是"平方根扩散"的原型:位置的标准差随步数按 `√t` 增长,所以典型图形是 `±√t` 的喇叭口。
|
|
||||||
本函数只负责 `cumsum`;**建模差异全在 `step_sampler` 里** —— 换成 `rng.normal(0, 1, size)` 就是高斯游走,
|
|
||||||
换成带正漂移的增量就是有趋势的游走。
|
|
||||||
`step_sampler` 返回的不是**一维**、长度不等于 `n_paths * n_steps`,或增量里出现 NaN,都判 `success=False`
|
|
||||||
(NaN 口径与 `monte_carlo` 一致);其余异常同样统一走失败分支。
|
|
||||||
步与路径的对应关系是**行主序**:一维数组的前 `n_steps` 个元素属于第 1 条路径。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> r = random_walk(n_steps=100, n_paths=5, random_state=0)
|
|
||||||
>>> r.data.shape
|
|
||||||
(5, 101)
|
|
||||||
>>> bool(np.all(np.abs(np.diff(r.data, axis=1)) == 1)) # 默认步长恒为 1
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
if isinstance(n_steps, bool) or not isinstance(n_steps, (int, np.integer)):
|
|
||||||
raise TypeError(f"n_steps 必须是整数,你传入了 {type(n_steps).__name__}")
|
|
||||||
if n_steps <= 0:
|
|
||||||
raise ValueError(f"n_steps 必须是正整数,你传入了 {n_steps}")
|
|
||||||
|
|
||||||
if isinstance(n_paths, bool) or not isinstance(n_paths, (int, np.integer)):
|
|
||||||
raise TypeError(f"n_paths 必须是整数,你传入了 {type(n_paths).__name__}")
|
|
||||||
if n_paths <= 0:
|
|
||||||
raise ValueError(f"n_paths 必须是正整数,你传入了 {n_paths}")
|
|
||||||
|
|
||||||
if not isinstance(start, (int, float, np.number)):
|
|
||||||
raise TypeError(f"start 必须是数值,你传入了 {type(start).__name__}")
|
|
||||||
|
|
||||||
rng = get_rng(random_state)
|
|
||||||
|
|
||||||
if step_sampler is None:
|
|
||||||
def _default_step_sampler(rng, size):
|
|
||||||
return rng.choice([-1.0, 1.0], size=size)
|
|
||||||
|
|
||||||
step_sampler = _default_step_sampler
|
|
||||||
|
|
||||||
call_params = {
|
|
||||||
"n_steps": n_steps,
|
|
||||||
"n_paths": n_paths,
|
|
||||||
"start": start,
|
|
||||||
"random_state": random_state,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
steps = np.asarray(step_sampler(rng, size=n_paths * n_steps), dtype=float)
|
|
||||||
if steps.shape != (n_paths * n_steps,):
|
|
||||||
raise ValueError(
|
|
||||||
f"step_sampler 返回长度必须为 {n_paths * n_steps},实际得到了 {steps.shape}"
|
|
||||||
)
|
|
||||||
if np.isnan(steps).any():
|
|
||||||
raise ValueError("step_sampler 生成的增量数据中包含 NaN,请检查采样逻辑")
|
|
||||||
|
|
||||||
steps = steps.reshape(n_paths, n_steps) # 行主序:前 n_steps 个属于第 1 条路径
|
|
||||||
|
|
||||||
cumsum = np.cumsum(steps, axis=1)
|
|
||||||
|
|
||||||
paths = np.zeros((n_paths, n_steps + 1), dtype=float)
|
|
||||||
paths[:, 0] = start
|
|
||||||
paths[:, 1:] = start + cumsum
|
|
||||||
|
|
||||||
terminal_positions = paths[:, -1]
|
|
||||||
metrics = {
|
|
||||||
"mean_terminal": float(np.mean(terminal_positions)),
|
|
||||||
"std_terminal": float(np.std(terminal_positions))
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result(
|
|
||||||
name="random_walk",
|
|
||||||
params=call_params,
|
|
||||||
metrics=metrics,
|
|
||||||
data=paths,
|
|
||||||
success=True,
|
|
||||||
message=f"随机游走模拟成功:生成了 {n_paths} 条路径,每条路径 {n_steps} 步。"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return Result(
|
|
||||||
name="random_walk",
|
|
||||||
params=call_params,
|
|
||||||
metrics={},
|
|
||||||
data=None,
|
|
||||||
success=False,
|
|
||||||
message=f"随机游走模拟失败:{type(e).__name__} - {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def markov_simulate(
|
|
||||||
transition: Any,
|
|
||||||
initial: Any,
|
|
||||||
steps: int | np.integer = 100,
|
|
||||||
n_paths: int | np.integer = 1,
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""马尔可夫链模拟:从 `initial` 出发,按 `transition` 逐行走 `steps` 步。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
transition:
|
|
||||||
行随机矩阵(每行和为 1)的二维方阵,状态数就是它的阶数 `s`。
|
|
||||||
initial:
|
|
||||||
初始状态。**标量整数**时所有路径都从同一状态出发;
|
|
||||||
否则按一维数组处理,长度必须等于状态数 `s`,再写进每条路径的第 0 列
|
|
||||||
—— 只有 `n_paths == s` 时两者才对得上,见 Notes。
|
|
||||||
steps:
|
|
||||||
每条路径的步数,正整数。`data` 的列数是 `steps + 1`(多出的第 0 列是初始状态)。
|
|
||||||
n_paths:
|
|
||||||
路径条数,正整数,默认 1 条。
|
|
||||||
random_state:
|
|
||||||
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是形状 `(n_paths, steps + 1)` 的整数状态序列,第 0 列是初始状态;
|
|
||||||
`metrics={"mean_terminal", "terminal_distribution"}` —— 前者是终点状态的均值,
|
|
||||||
后者是 `{状态: 出现频率}` 的 dict(按终点统计,只含**实际出现过**的状态,没出现的状态没有 key)。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError:
|
|
||||||
`transition` 不是二维数组;`initial` 数组长度不等于状态数;
|
|
||||||
`steps` / `n_paths` 不是整数。
|
|
||||||
ValueError:
|
|
||||||
`transition` 不是方阵,或某一行和不等于 1.0;`steps` / `n_paths` 不是正整数。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
抽样用逆变换法:`cumsum` 得到累积分布,再拿 `[0, 1)` 的均匀数去"落格"。
|
|
||||||
一次抽完 `n_paths` 个状态,路径之间相互独立;步与步之间只能串行,因为下一步依赖当前状态。
|
|
||||||
`cum_probs[:, -1] = 1.0` 是为了压掉浮点累加误差,避免随机数落在 `[1 - 1e-16, 1)` 时索引越界。
|
|
||||||
终点分布随步数收敛到平稳分布:这既是本函数的自检手段,也是论文里值得写一句的性质。
|
|
||||||
|
|
||||||
遗留(决策 56):`initial` 靠 `isinstance(initial, (int, np.integer))` 区分"标量状态 / 状态数组",
|
|
||||||
`np.int64` 已收,但 `bool` 仍会被当成状态 1(`isinstance(True, int)` 为真);
|
|
||||||
且数组分支"按**状态数**校验长度"与真正写进去时"要按**路径数**"仍互相矛盾 —— 只有 `n_paths == s` 才跑得通。
|
|
||||||
状态越界(如 `initial=5`、`s=2`)也要等第一轮索引才炸。口径定了这段随之改写。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
>>> r = markov_simulate(P, initial=0, steps=50, n_paths=4, random_state=0)
|
|
||||||
>>> r.data.shape
|
|
||||||
(4, 51)
|
|
||||||
>>> bool(np.all(r.data[:, 0] == 0))
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
arr, _ = as_array(transition)
|
|
||||||
if arr.ndim != 2 or arr.shape[0] != arr.shape[1]:
|
|
||||||
raise ValueError(f"transition 必须是二维方阵,你传入的形状是 {arr.shape}")
|
|
||||||
if np.any(arr < -1e-12):
|
|
||||||
raise ValueError("transition 矩阵的元素必须是非负的")
|
|
||||||
if not np.allclose(arr.sum(axis=1), 1.0):
|
|
||||||
raise ValueError("transition 每一行的和必须是 1.0")
|
|
||||||
s = arr.shape[0]
|
|
||||||
|
|
||||||
if isinstance(n_paths, bool) or not isinstance(n_paths, (int, np.integer)):
|
|
||||||
raise TypeError(f"n_paths 必须是整数,你传入了 {type(n_paths).__name__}")
|
|
||||||
if n_paths <= 0:
|
|
||||||
raise ValueError(f"n_paths 必须是正整数,你传入了 {n_paths}")
|
|
||||||
if isinstance(steps, bool) or not isinstance(steps, (int, np.integer)):
|
|
||||||
raise TypeError(f"steps 必须是整数,你传入了 {type(steps).__name__}")
|
|
||||||
if steps <= 0:
|
|
||||||
raise ValueError(f"steps 必须是正整数,你传入了 {steps}")
|
|
||||||
|
|
||||||
rng = get_rng(random_state)
|
|
||||||
|
|
||||||
if isinstance(initial, (int,np.integer)):
|
|
||||||
current_states = np.full(n_paths, initial)
|
|
||||||
initial_param = int(initial)
|
|
||||||
else:
|
|
||||||
current_states, _ = as_array(initial, ndim=1)
|
|
||||||
if len(current_states) != s:
|
|
||||||
raise TypeError(f"initial 的长度必须是 {s},而你传入的是 {len(current_states)}")
|
|
||||||
initial_param = current_states.tolist()
|
|
||||||
|
|
||||||
call_params = {
|
|
||||||
"steps": steps,
|
|
||||||
"n_paths": n_paths,
|
|
||||||
"initial": initial_param,
|
|
||||||
"random_state": random_state
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
paths = np.zeros((n_paths, steps + 1), dtype=int)
|
|
||||||
paths[:, 0] = current_states
|
|
||||||
for t in range(steps):
|
|
||||||
probs = arr[current_states]
|
|
||||||
|
|
||||||
cum_probs = np.cumsum(probs, axis=1)
|
|
||||||
cum_probs[:, -1] = 1.0
|
|
||||||
|
|
||||||
r = rng.random(n_paths)
|
|
||||||
next_states = (cum_probs < r[:, None]).sum(axis=1)
|
|
||||||
|
|
||||||
paths[:, t + 1] = next_states
|
|
||||||
current_states = next_states
|
|
||||||
|
|
||||||
terminal_positions = paths[:, -1]
|
|
||||||
unique, counts = np.unique(terminal_positions, return_counts=True)
|
|
||||||
terminal_dist = {int(state): float(count) / n_paths for state, count in zip(unique, counts, strict=True)}
|
|
||||||
|
|
||||||
metrics = {
|
|
||||||
"mean_terminal": float(np.mean(terminal_positions)),
|
|
||||||
"terminal_distribution": terminal_dist
|
|
||||||
}
|
|
||||||
return Result(
|
|
||||||
name="markov_simulate",
|
|
||||||
params=call_params,
|
|
||||||
metrics=metrics,
|
|
||||||
data=paths,
|
|
||||||
success=True,
|
|
||||||
message=f"马尔可夫链模拟成功,生成了 {n_paths} 条路径,每条路径 {steps} 步",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return Result(
|
|
||||||
name="markov_simulate",
|
|
||||||
params=call_params,
|
|
||||||
metrics={},
|
|
||||||
data=None,
|
|
||||||
success=False,
|
|
||||||
message=f"马尔可夫链模拟失败:{type(e).__name__} - {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def markov_steady_state(transition: Any, max_iter: int | np.integer = 10000, tol: float = 1e-10) -> Any:
|
|
||||||
"""迭代法求马尔可夫链的平稳分布(`v @ P` 反复推进,直到相邻两次几乎不变)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
transition:
|
|
||||||
行随机矩阵(每行和为 1)的二维方阵。
|
|
||||||
max_iter:
|
|
||||||
迭代上限,正整数,默认 10000。
|
|
||||||
tol:
|
|
||||||
收敛阈值,正数,默认 1e-10。判据是**相邻两次迭代的最大分量差**
|
|
||||||
`max|v_k+1 - v_k| < tol`,不是"到不动点的距离",见 Notes。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是一维平稳分布向量(和为 1);
|
|
||||||
`metrics={"converged", "iterations"}`(是否在 `max_iter` 内向容、实际迭代次数)。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError:
|
|
||||||
`transition` 不是二维方阵;`max_iter` 不是整数;`tol` 不是数值。
|
|
||||||
ValueError:
|
|
||||||
`transition` 含负元素、或某一行和不等于 1.0;
|
|
||||||
`max_iter` 不是正整数;`tol` 不是正数(NaN 也算非法)。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
用迭代法而不是特征分解:迭代法能顺便告诉你"收敛快不快",
|
|
||||||
而收敛速度本身就是论文里值得写一句的性质。
|
|
||||||
初值是**均匀分布** `1/s`;**不可约 + 非周期**才有唯一平稳分布。
|
|
||||||
可约链(如单位阵 `eye(2)`)的平稳分布不唯一,迭代法给出的是"离初值最近的那个"
|
|
||||||
—— 均匀初值对双随机矩阵本身就是不动点,所以 `converged=True, iterations=1` 只说明"初值恰是不动点",
|
|
||||||
不代表这条链有唯一平稳分布;周期链从一般初值出发会一直振荡、`converged=False`。
|
|
||||||
|
|
||||||
`tol` 是**相邻差**口径,实际误差约被 `1 / (1 - |λ2|)` 放大:实测双状态慢混合链
|
|
||||||
`[[0.999, 0.001], [0.02, 0.98]]`(`|λ2| = 0.979`)用默认 `tol=1e-10` 停在第 867 次,
|
|
||||||
与特征向量相差 `4.6e-9`(约 50 倍)。慢混合链要更准,把 `tol` 压小即可。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
>>> r = markov_steady_state(P)
|
|
||||||
>>> bool(np.allclose(r.data, [2 / 3, 1 / 3], atol=1e-6))
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
arr, _ = as_array(transition)
|
|
||||||
if arr.ndim != 2 or arr.shape[0] != arr.shape[1]:
|
|
||||||
raise TypeError(f"transition 必须是二维方阵,你传入的形状是 {arr.shape}")
|
|
||||||
if np.any(arr < -1e-12):
|
|
||||||
raise ValueError("transition 矩阵的元素必须是非负的(概率不能为负)")
|
|
||||||
if not np.allclose(arr.sum(axis=1), 1.0):
|
|
||||||
raise ValueError("transition 每一行的和必须是 1.0")
|
|
||||||
|
|
||||||
if isinstance(max_iter, bool) or not isinstance(max_iter, (int, np.integer)):
|
|
||||||
raise TypeError(f"max_iter 必须是正整数,你传入了 {type(max_iter).__name__}")
|
|
||||||
if max_iter <= 0:
|
|
||||||
raise ValueError(f"max_iter 必须是正整数,你传入了 {max_iter}")
|
|
||||||
if not isinstance(tol, (int, float)) or isinstance(tol, bool):
|
|
||||||
raise TypeError(f"tol 必须是浮点数,你传入了 {type(tol).__name__}")
|
|
||||||
if tol <= 0 or np.isnan(tol):
|
|
||||||
raise ValueError(f"tol 必须是正数,你传入了 {tol}")
|
|
||||||
|
|
||||||
s = arr.shape[0]
|
|
||||||
v = np.full(s, 1.0 / s)
|
|
||||||
|
|
||||||
iterations = 0
|
|
||||||
converged = False
|
|
||||||
call_params = {
|
|
||||||
"transition": arr.tolist(),
|
|
||||||
"max_iter": max_iter,
|
|
||||||
"tol": tol,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
for _ in range(max_iter):
|
|
||||||
v_next = v @ arr
|
|
||||||
error = np.max(np.abs(v_next - v))
|
|
||||||
v = v_next
|
|
||||||
iterations += 1
|
|
||||||
|
|
||||||
if error < tol:
|
|
||||||
converged = True
|
|
||||||
break
|
|
||||||
|
|
||||||
metrics = {
|
|
||||||
"converged": converged,
|
|
||||||
"iterations": iterations
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result(
|
|
||||||
name="markov_steady_state",
|
|
||||||
params=call_params,
|
|
||||||
metrics=metrics,
|
|
||||||
data=v,
|
|
||||||
success=converged,
|
|
||||||
message=f"平稳分布求解{'成功' if converged else '失败:未在最大迭代次数内收敛'},迭代 {iterations} 次。"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return Result(
|
|
||||||
name="markov_steady_state",
|
|
||||||
params=call_params,
|
|
||||||
metrics={},
|
|
||||||
data=None,
|
|
||||||
success=False,
|
|
||||||
message=f"平稳分布求解异常:{type(e).__name__} - {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def mm1_metrics(arrival_rate: float, service_rate: float) -> Any:
|
|
||||||
"""M/M/1 排队系统的闭式指标。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
arrival_rate, service_rate:
|
|
||||||
到达率 λ 与服务率 μ,都收 `int` / `float` / numpy 标量。
|
|
||||||
`λ` 允许为 0(空系统:`L = Lq = Wq = 0`,`W` 取极限值 `1/μ`);
|
|
||||||
`λ >= μ` 时系统不稳定、队列长度无界 —— 这种情况返回 `success=False` 并说明原因,而不是返回 `inf`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`metrics={"rho", "L", "Lq", "W", "Wq"}`
|
|
||||||
(系统利用率、系统内平均顾客数、队列长度、平均逗留时间、平均等待时间);
|
|
||||||
`data` 与 `metrics` 同值(闭式解没有样本数组),但**不是同一个对象**。
|
|
||||||
不稳定时 `success=False`、`metrics={{"rho"}}`(只报利用率)、`data=None`。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError:
|
|
||||||
`arrival_rate` / `service_rate` 不是数值(`bool` 也判非法)。
|
|
||||||
ValueError:
|
|
||||||
`arrival_rate` 是负数;`service_rate` 不是正数。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
M/M/1 是唯一值得手写的排队模型:公式是闭式的,10 行搞定。
|
|
||||||
多服务员(M/M/c)和一般服务时间分布(M/G/1)在美赛出现频率极低,
|
|
||||||
真遇到时现场用仿真做,不做通用封装。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = mm1_metrics(arrival_rate=2, service_rate=3)
|
|
||||||
>>> round(r.metrics["rho"], 4)
|
|
||||||
0.6667
|
|
||||||
"""
|
|
||||||
if not isinstance(arrival_rate, (int, float, np.number)) or isinstance(arrival_rate, bool):
|
|
||||||
raise TypeError(f"arrival_rate 必须是浮点数,你传入了 {type(arrival_rate).__name__}")
|
|
||||||
if arrival_rate < 0:
|
|
||||||
raise ValueError(f"arrival_rate 必须是非负数,你传入了 {arrival_rate}")
|
|
||||||
if not isinstance(service_rate, (int, float, np.number)) or isinstance(service_rate, bool):
|
|
||||||
raise TypeError(f"service_rate 必须是浮点数,你传入了 {type(service_rate).__name__}")
|
|
||||||
if service_rate <= 0:
|
|
||||||
raise ValueError(f"service_rate 必须是正数,你传入了 {service_rate}")
|
|
||||||
|
|
||||||
call_params = {"arrival_rate": float(arrival_rate), "service_rate": float(service_rate)}
|
|
||||||
|
|
||||||
if arrival_rate >= service_rate:
|
|
||||||
rho = arrival_rate / service_rate
|
|
||||||
return Result(
|
|
||||||
name="mm1_metrics",
|
|
||||||
params=call_params,
|
|
||||||
metrics={"rho": float(rho)},
|
|
||||||
data=None,
|
|
||||||
success=False,
|
|
||||||
message=f"系统不稳定:λ >= μ,队列无界。当前利用率 rho={rho:.4f}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
rho = arrival_rate / service_rate
|
|
||||||
L = rho / (1.0 - rho) # 系统内平均顾客数
|
|
||||||
Lq = rho ** 2 / (1.0 - rho) # 队列中平均等待顾客数
|
|
||||||
W = 1.0 / (service_rate - arrival_rate) # 平均逗留时间
|
|
||||||
Wq = W - 1.0 / service_rate # 平均等待时间
|
|
||||||
|
|
||||||
metrics = {
|
|
||||||
"rho": float(rho),
|
|
||||||
"L": float(L),
|
|
||||||
"Lq": float(Lq),
|
|
||||||
"W": float(W),
|
|
||||||
"Wq": float(Wq)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result(
|
|
||||||
name="mm1_metrics",
|
|
||||||
params=call_params,
|
|
||||||
metrics=metrics,
|
|
||||||
data=metrics.copy(),
|
|
||||||
success=True,
|
|
||||||
message="M/M/1 排队系统指标计算成功。"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return Result(
|
|
||||||
name="mm1_metrics",
|
|
||||||
params=call_params,
|
|
||||||
metrics={},
|
|
||||||
data=None,
|
|
||||||
success=False,
|
|
||||||
message=f"计算失败:{type(e).__name__} - {str(e)}"
|
|
||||||
)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""stats —— 描述统计、统计推断、bootstrap。
|
|
||||||
|
|
||||||
S 级模块。`bootstrap_ci` 建议自己实现一遍(S 档,配完整函数卡片):
|
|
||||||
它是赛题里"没有解析解时怎么给区间"的通用答案。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .bootstrap import bootstrap_ci, bootstrap_statistic
|
|
||||||
from .descriptive import coefficient_of_variation, describe, zscore
|
|
||||||
from .inference import (
|
|
||||||
anova,
|
|
||||||
chi_square_test,
|
|
||||||
confidence_interval,
|
|
||||||
correlation_matrix,
|
|
||||||
correlation_pvalues,
|
|
||||||
correlation_table,
|
|
||||||
fit_distribution,
|
|
||||||
goodness_of_fit,
|
|
||||||
t_test,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"describe",
|
|
||||||
"zscore",
|
|
||||||
"coefficient_of_variation",
|
|
||||||
"correlation_matrix",
|
|
||||||
"correlation_pvalues",
|
|
||||||
"correlation_table",
|
|
||||||
"confidence_interval",
|
|
||||||
"t_test",
|
|
||||||
"anova",
|
|
||||||
"chi_square_test",
|
|
||||||
"fit_distribution",
|
|
||||||
"goodness_of_fit",
|
|
||||||
"bootstrap_statistic",
|
|
||||||
"bootstrap_ci",
|
|
||||||
]
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""Bootstrap 重采样。
|
|
||||||
|
|
||||||
★ S 档模块:建议自己完整实现一遍。
|
|
||||||
它是"没有解析解时怎么给区间"的通用答案,赛题里出现频率极高,
|
|
||||||
而且实现只有几十行——投入产出比最高的一个算法。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from ..core import Result, as_array, get_rng
|
|
||||||
|
|
||||||
__all__ = ["bootstrap_ci", "bootstrap_statistic"]
|
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_statistic(
|
|
||||||
x: Any,
|
|
||||||
statistic: Callable[..., float],
|
|
||||||
n_resamples: int = 10000,
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""返回 `n_resamples` 个重采样统计量组成的数组。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> vals = bootstrap_statistic([1, 2, 3, 4, 5], np.mean, n_resamples=200, random_state=0)
|
|
||||||
"""
|
|
||||||
# TODO(L1) ★ S 档
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_ci(
|
|
||||||
x: Any,
|
|
||||||
statistic: Callable[..., float] | None = None,
|
|
||||||
confidence: float = 0.95,
|
|
||||||
n_resamples: int = 10000,
|
|
||||||
method: str = "percentile",
|
|
||||||
random_state: int | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""用重采样给出任意统计量的置信区间。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
x:
|
|
||||||
样本:`pandas.DataFrame` / `pandas.Series` / `numpy.ndarray` / list / tuple。
|
|
||||||
走 `core.as_array(x, numeric_only=True, ndim=1)` —— DataFrame 只取数值列,再摊平成一串样本。
|
|
||||||
statistic:
|
|
||||||
任意把样本映射成标量的函数,默认 `np.mean`。
|
|
||||||
换成 `np.median` / `np.std` / 自定义的 `lambda s: s.max() - s.min()` 都能用,
|
|
||||||
这是 bootstrap 相对于 `mean ± z·s/√n` 的核心优势。
|
|
||||||
confidence:
|
|
||||||
置信水平,取 `(0, 1)` 开区间,默认 0.95。
|
|
||||||
n_resamples:
|
|
||||||
重采样次数,默认 10000。论文里必须写明。
|
|
||||||
method:
|
|
||||||
`"percentile"`:直接取经验分布的 α/2 与 1-α/2 分位(最常用,默认);
|
|
||||||
`"bca"`:偏差校正 + 加速,区间更准但慢,样本有偏时用 —— **尚未实现**,传了抛
|
|
||||||
`NotImplementedError`。
|
|
||||||
random_state:
|
|
||||||
随机种子。传 `None` 时复用 `core.get_rng` 的**模块级生成器且会推进**,
|
|
||||||
所以同一段脚本里连调两次结果不同;要完全可复现就显式给种子,或先 `core.set_seed`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`params={"estimate", "low", "high", "confidence", "n_resamples"}`;
|
|
||||||
`metrics["statistic_name"]` 是统计量的名字(自定义 lambda 时是 `"custom"`)。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`x` 不是受支持的容器,或内容转不成数值(由 `as_array` 抛)。
|
|
||||||
ValueError
|
|
||||||
`DataFrame` 里一列数值都没有;`method` 不在支持列表内。
|
|
||||||
NotImplementedError
|
|
||||||
`method="bca"`。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
* 重采样时必须**按块处理**,不要一次生成 `(10000, n)` 的整矩阵(n 大时会爆内存)。
|
|
||||||
* 样本量 n < 10 时 bootstrap 不可靠,先补数据。
|
|
||||||
* 比赛写论文时要注明:n_resamples、seed、用的是 percentile 还是 BCa(目前只实现了 percentile)。
|
|
||||||
* **缺失值目前不处理**:`x` 里的 NaN 会直接进统计量,`np.mean` 于是给出 nan 区间。
|
|
||||||
`confidence_interval` / `t_test` 都是先丢掉 NaN 的,两边口径尚未统一。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import numpy as np
|
|
||||||
>>> r = bootstrap_ci([1, 2, 3, 4, 5], np.median, random_state=0)
|
|
||||||
>>> r.params["low"] < 3 < r.params["high"]
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
if statistic is None:
|
|
||||||
statistic = np.mean
|
|
||||||
|
|
||||||
rng = get_rng(random_state)
|
|
||||||
|
|
||||||
arr, _ = as_array(x=x, dtype=float, ndim=1)
|
|
||||||
|
|
||||||
original_estimate = float(statistic(arr))
|
|
||||||
|
|
||||||
n = len(arr)
|
|
||||||
batch_size = 1000
|
|
||||||
boot_stats = []
|
|
||||||
|
|
||||||
for i in range(0, n_resamples, batch_size):
|
|
||||||
current_batch = min(batch_size, n_resamples - i)
|
|
||||||
indices = rng.integers(0, n, size=(current_batch, n))
|
|
||||||
batch_samples = arr[indices]
|
|
||||||
|
|
||||||
if statistic in [np.mean, np.median, np.std, np.min, np.max]:
|
|
||||||
batch_stats = statistic(batch_samples, axis=1)
|
|
||||||
else:
|
|
||||||
batch_stats = np.array([statistic(s) for s in batch_samples])
|
|
||||||
|
|
||||||
boot_stats.extend(np.asarray(batch_stats).ravel().tolist())
|
|
||||||
|
|
||||||
boot_stats = np.array(boot_stats)
|
|
||||||
|
|
||||||
if method == 'percentile':
|
|
||||||
alpha = 1 - confidence
|
|
||||||
lower = np.percentile(boot_stats, alpha / 2 * 100)
|
|
||||||
upper = np.percentile(boot_stats, (1 - alpha / 2) * 100)
|
|
||||||
elif method == 'bca':
|
|
||||||
# TODO(L1): BCa = 偏差校正 + 加速,属于 S 档、留给用户自己补的算法。
|
|
||||||
raise NotImplementedError("BCa 还没实现,先用 method='percentile'")
|
|
||||||
else:
|
|
||||||
raise ValueError(f"不支持的方法:{method},仅支持'percentile'")
|
|
||||||
|
|
||||||
# lambda 也有 __name__(值恒为 "<lambda>"),所以只判断 hasattr 会让 "custom" 这支变成死代码。
|
|
||||||
name = getattr(statistic, "__name__", None)
|
|
||||||
statistic_name = "custom" if name in (None, "<lambda>") else str(name)
|
|
||||||
|
|
||||||
return Result(
|
|
||||||
name="bootstrap_ci",
|
|
||||||
params={
|
|
||||||
"estimate": original_estimate,
|
|
||||||
"low": float(lower),
|
|
||||||
"high": float(upper),
|
|
||||||
"confidence": confidence,
|
|
||||||
"n_resamples": n_resamples
|
|
||||||
},
|
|
||||||
metrics={
|
|
||||||
"statistic_name": statistic_name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
"""描述统计。
|
|
||||||
|
|
||||||
每个函数都只是"一句话数学含义 + 一句话什么时候用"(A 档),
|
|
||||||
不单独写文档卡片。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from ..core.validate import as_array
|
|
||||||
|
|
||||||
__all__ = ["coefficient_of_variation", "describe", "zscore"]
|
|
||||||
|
|
||||||
|
|
||||||
def describe(x: Any, weights: Any | None = None) -> pd.DataFrame:
|
|
||||||
"""一张表看完集中趋势、离散程度、偏度峰度、缺失情况。
|
|
||||||
|
|
||||||
`weights` 非空时给出加权均值与加权标准差(权重是"频数"或"重要性"时用)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
x:
|
|
||||||
`pandas.DataFrame` / `pandas.Series` / list / tuple / `numpy.ndarray`。
|
|
||||||
**只描述数值列**,非数值列自动跳过。
|
|
||||||
weights:
|
|
||||||
与 `x` 行数等长的**非负**权重,类型同 `x` 可接受的那几种;
|
|
||||||
`None` 表示不给加权行。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
pandas.DataFrame
|
|
||||||
行是指标,列是被描述的变量。指标依次为
|
|
||||||
`count` / `mean` / `std` / `min` / `25%` / `50%` / `75%` / `max` /
|
|
||||||
`median` / `missing` / `missing_ratio` / `skewness` / `kurtosis`;
|
|
||||||
给了 `weights` 时再追加 `weighted_mean` / `weighted_std`。
|
|
||||||
返回的是新表,原表不改。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`x` 或 `weights` 的类型不支持。
|
|
||||||
ValueError
|
|
||||||
`x` 里没有数值列;或 `weights` 无法转成数值、不是一维、长度与行数不符、
|
|
||||||
含 NaN / inf、含负值、求和为 0。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
每个指标都**按列独立**计算;含缺失时 pandas 会自动跳过该单元格(`count` 因此
|
|
||||||
可能小于行数,缺失量看 `missing` / `missing_ratio`)。
|
|
||||||
加权标准差用总体口径(`ddof=0`)。含 NaN 的列加权后仍是 NaN ——
|
|
||||||
加权前请先用 `impute_missing` 补好。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> describe(df).loc["mean"] # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
allowed_types = (pd.DataFrame, pd.Series, list, tuple, np.ndarray)
|
|
||||||
if not isinstance(x, allowed_types):
|
|
||||||
raise TypeError(f"x 必须是{allowed_types},而你输入了{type(x)}")
|
|
||||||
|
|
||||||
if not isinstance(x, pd.DataFrame):
|
|
||||||
x = pd.DataFrame(x)
|
|
||||||
|
|
||||||
numeric_cols = x.select_dtypes(include=["number"]).columns
|
|
||||||
if len(numeric_cols) == 0:
|
|
||||||
raise ValueError("x 里没有数值列,无法描述")
|
|
||||||
|
|
||||||
# df[Index] 在 pandas 存根里是 DataFrame | Series 重载,按运行时真值收窄(坑 8)
|
|
||||||
df_num = cast(pd.DataFrame, x[numeric_cols])
|
|
||||||
|
|
||||||
# 这里**不要转置**。describe() 出来就是"行 = 指标、列 = 变量",正好让下面
|
|
||||||
# `.loc[指标名] = <按变量索引的 Series>` 能按列名对齐;一旦转置,Series 的索引
|
|
||||||
# 与表的列名不相交,pandas 会不报错地**把整行填成 NaN**(旧的静默 bug)。
|
|
||||||
table = df_num.describe()
|
|
||||||
table.loc["median"] = df_num.median()
|
|
||||||
table.loc["missing"] = df_num.isna().sum()
|
|
||||||
table.loc["missing_ratio"] = df_num.isna().mean()
|
|
||||||
table.loc["skewness"] = df_num.skew()
|
|
||||||
table.loc["kurtosis"] = df_num.kurt()
|
|
||||||
|
|
||||||
if weights is not None:
|
|
||||||
allowed_weight_types = (list, tuple, np.ndarray, pd.Series)
|
|
||||||
if not isinstance(weights, allowed_weight_types):
|
|
||||||
raise TypeError(f"weights 必须是{allowed_weight_types},而你输入了{type(weights)}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
w_arr = np.asarray(weights, dtype=float)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
raise ValueError(f"weights 无法转成数值数组,请检查是否含非数值字符:{e}") from None
|
|
||||||
|
|
||||||
if w_arr.ndim != 1:
|
|
||||||
raise ValueError(f"weights 必须是一维数组,而你传入了 {w_arr.ndim} 维")
|
|
||||||
if len(w_arr) != len(df_num):
|
|
||||||
raise ValueError(f"权重 weights 的长度({len(w_arr)})必须与数据的行数({len(df_num)})一致")
|
|
||||||
if not np.isfinite(w_arr).all():
|
|
||||||
raise ValueError("weights 含 NaN 或 inf,无法加权")
|
|
||||||
if (w_arr < 0).any():
|
|
||||||
raise ValueError("weights 不能含负值")
|
|
||||||
if w_arr.sum() == 0:
|
|
||||||
raise ValueError("weights 之和为 0,无法加权")
|
|
||||||
|
|
||||||
weighted_mean = np.average(df_num, axis=0, weights=w_arr)
|
|
||||||
weighted_std = np.sqrt(np.average((df_num - weighted_mean) ** 2, axis=0, weights=w_arr))
|
|
||||||
|
|
||||||
table.loc["weighted_mean"] = weighted_mean
|
|
||||||
table.loc["weighted_std"] = weighted_std
|
|
||||||
|
|
||||||
return table
|
|
||||||
|
|
||||||
|
|
||||||
def zscore(x: Any, ddof: int = 0) -> Any:
|
|
||||||
"""标准化到 z 分数,顺带回答了"这个值离均值几个标准差"。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
x:
|
|
||||||
`pandas.DataFrame` / `pandas.Series` / list / tuple / `numpy.ndarray`。
|
|
||||||
DataFrame **只取数值列**,非数值列不会出现在结果里。
|
|
||||||
ddof:
|
|
||||||
标准差的自由度修正,与 `numpy.std` 同名参数含义一致;`0` 为总体标准差。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
numpy.ndarray / pandas.Series / pandas.DataFrame
|
|
||||||
与 `x` 同类型;DataFrame 只保留数值列,列名与索引不变。
|
|
||||||
常量列(`std == 0`)不产生 nan / inf,固定输出 0(决策 34)。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`x` 类型不支持。
|
|
||||||
ValueError
|
|
||||||
DataFrame 里一列数值列都没有。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
与 `data.transform.standardize` 是同一个公式;差别只在 DataFrame 里遇到
|
|
||||||
非数值列时:这里**跳过**,`standardize` 会直接抛错。
|
|
||||||
"""
|
|
||||||
arr, kind = as_array(x, numeric_only=True)
|
|
||||||
|
|
||||||
mean = np.mean(arr, axis=0)
|
|
||||||
std = np.std(arr, axis=0, ddof=ddof)
|
|
||||||
|
|
||||||
std = np.where(std == 0, 1.0, std)
|
|
||||||
|
|
||||||
result = (arr - mean) / std
|
|
||||||
if kind == "frame":
|
|
||||||
# 列名口径与 as_array(numeric_only=True) 一致,都是只取数值列
|
|
||||||
cols = x.select_dtypes(include=[np.number]).columns
|
|
||||||
return pd.DataFrame(result, index=x.index, columns=cols)
|
|
||||||
if kind == "series":
|
|
||||||
return pd.Series(result, index=x.index, name=x.name)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def coefficient_of_variation(x: Any) -> float:
|
|
||||||
"""变异系数 `std / |mean|` —— 跨量纲比较"谁更波动"时的唯一正确做法。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
x:
|
|
||||||
一维数据:`pandas.Series` / list / tuple / `numpy.ndarray`,
|
|
||||||
或只有一列的 `pandas.DataFrame`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
float
|
|
||||||
`std / |mean|`,标准差按总体口径(`ddof=0`)。恒非负。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`x` 类型不支持。
|
|
||||||
ValueError
|
|
||||||
数据为空、不是一维(或 DataFrame 列数不为 1)、或均值为 0。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
分母取的是 `|mean|`:均值为负时 CV 仍是正数,不会因为符号反过来被误读成"波动更小"。
|
|
||||||
但 CV 的**可比性**仍然只在均值远离 0 的比率尺度数据上成立 —— 均值接近 0 时
|
|
||||||
CV 会被放大到任意大,那种数据请换用别的离散度指标。
|
|
||||||
"""
|
|
||||||
allowed_type = (list, tuple, np.ndarray, pd.Series)
|
|
||||||
if isinstance(x, pd.DataFrame):
|
|
||||||
if x.shape[1] != 1:
|
|
||||||
raise ValueError(f"变异系数只适用于一维数据,而你传入了 {x.shape[1]} 列的 DataFrame")
|
|
||||||
arr = np.asarray(x.iloc[:, 0], dtype=float)
|
|
||||||
elif isinstance(x, allowed_type):
|
|
||||||
arr = np.asarray(x, dtype=float)
|
|
||||||
if arr.ndim != 1:
|
|
||||||
raise ValueError(f"变异系数只适用于一维数据,而你传入了 {arr.ndim} 维数组")
|
|
||||||
else:
|
|
||||||
raise TypeError(f"x 必须是{allowed_type}或单列 pd.DataFrame,而你传入了{type(x)}")
|
|
||||||
|
|
||||||
if arr.size == 0:
|
|
||||||
raise ValueError("数据为空,无法计算变异系数")
|
|
||||||
|
|
||||||
mean = np.mean(arr)
|
|
||||||
std = np.std(arr, ddof=0)
|
|
||||||
|
|
||||||
if mean == 0:
|
|
||||||
raise ValueError("均值为 0,无法计算变异系数(分母为 0)")
|
|
||||||
|
|
||||||
return np.abs(float(std / mean))
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
"""统计推断:区间估计、假设检验、分布拟合。
|
|
||||||
|
|
||||||
原建议里的 `mean_test`/`proportion_test`/`distribution_pdf`/`distribution_cdf`/
|
|
||||||
`distribution_ppf`/`sample_distribution` 都是 scipy 的一行转发,已删。
|
|
||||||
需要 pdf/cdf/ppf 时,用 `fit_distribution(...).dist.pdf(...)` 即可。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Literal, cast
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
import scipy.stats as stats
|
|
||||||
|
|
||||||
from ..core.validate import as_array
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"anova",
|
|
||||||
"chi_square_test",
|
|
||||||
"confidence_interval",
|
|
||||||
"correlation_matrix",
|
|
||||||
"correlation_pvalues",
|
|
||||||
"correlation_table",
|
|
||||||
"fit_distribution",
|
|
||||||
"goodness_of_fit",
|
|
||||||
"t_test",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _pvalue(result: Any) -> float:
|
|
||||||
"""把 scipy 检验结果取成 p 值(float)。
|
|
||||||
|
|
||||||
scipy 的 `SignificanceResult` / `PearsonRResult` 是 `_make_tuple_bunch` 在**运行时**
|
|
||||||
拼出来的类,类型检查器看不到 `pvalue` 字段 —— 这里显式收成 `Any` 再取,
|
|
||||||
比给每一处调用挂 `# type: ignore` 干净,后面 `t_test` / `anova` 也能复用。
|
|
||||||
"""
|
|
||||||
return float(result.pvalue)
|
|
||||||
|
|
||||||
|
|
||||||
def _statistic(result: Any) -> float:
|
|
||||||
"""把 scipy 检验结果取成**统计量**(float)。
|
|
||||||
|
|
||||||
与 `_pvalue` 同一个原因:`TtestResult` 也是 `_make_tuple_bunch` 运行时拼的类,
|
|
||||||
存根里看不到 `statistic` 字段(见 `docs/04-environment.md` 坑 10)。
|
|
||||||
"""
|
|
||||||
return float(result.statistic)
|
|
||||||
|
|
||||||
|
|
||||||
_VALID_CORR_METHODS = ("pearson", "kendall", "spearman")
|
|
||||||
_VALID_CONFIDENCE_METHODS = ("normal", "t", "proportion")
|
|
||||||
|
|
||||||
|
|
||||||
def _check_corr_args(data: Any, method: str) -> pd.DataFrame:
|
|
||||||
"""三个 `correlation_*` 共用的入参守卫,返回收窄后的**数值子表**。"""
|
|
||||||
if not isinstance(data, pd.DataFrame):
|
|
||||||
raise TypeError(f"data必须是 pd.DataFrame,而你传的是{type(data)}")
|
|
||||||
if method not in _VALID_CORR_METHODS:
|
|
||||||
raise ValueError(f"不支持的方法:{method},仅支持{list(_VALID_CORR_METHODS)}")
|
|
||||||
numeric_cols = data.select_dtypes(include=[np.number]).columns
|
|
||||||
if len(numeric_cols) == 0:
|
|
||||||
raise ValueError("没有找到数值列")
|
|
||||||
# data[Index] 在 pandas 存根里是 DataFrame | Series 重载,按运行时真值收窄(坑 8)
|
|
||||||
return cast(pd.DataFrame, data[numeric_cols])
|
|
||||||
|
|
||||||
|
|
||||||
def correlation_matrix(
|
|
||||||
data: Any,
|
|
||||||
method: Literal["pearson", "kendall", "spearman"] = "pearson",
|
|
||||||
min_periods: int = 1,
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""相关系数矩阵(**数值**,不带星标)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
data:
|
|
||||||
必须是 `pandas.DataFrame`。**只有数值列参与**,非数值列自动跳过。
|
|
||||||
method:
|
|
||||||
`"pearson"` / `"kendall"` / `"spearman"`。
|
|
||||||
min_periods:
|
|
||||||
每对变量至少要有这么多个**共同非缺失**的观测才给相关系数,透传给 `DataFrame.corr`。
|
|
||||||
(pandas 对每对变量都是**成对删缺失**,不是整行删。)
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
pandas.DataFrame
|
|
||||||
对称矩阵,对角线恒为 `1.0`;观测不足的格子是 NaN。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`data` 不是 `pandas.DataFrame`。
|
|
||||||
ValueError
|
|
||||||
`data` 里没有数值列,或 `method` 不在支持列表内。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
要"系数 + 显著性星标"的论文表格用 `correlation_table`;要 p 值矩阵用 `correlation_pvalues`。
|
|
||||||
之所以拆开:这张表是**数值**,能挑高相关对、喂聚类 / PCA、画热力图;星标字符串表这些都做不了。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> correlation_matrix(df).round(3) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
df_num = _check_corr_args(data, method)
|
|
||||||
return df_num.corr(method=method, min_periods=min_periods)
|
|
||||||
|
|
||||||
|
|
||||||
def correlation_pvalues(
|
|
||||||
data: Any,
|
|
||||||
method: Literal["pearson", "kendall", "spearman"] = "pearson",
|
|
||||||
min_periods: int = 1,
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""`correlation_matrix` 对应的 **p 值矩阵**(数值,不含星标)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
data / method / min_periods:
|
|
||||||
含义与 `correlation_matrix` 完全一致,两者一定同形。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
pandas.DataFrame
|
|
||||||
对称矩阵。**对角线是 NaN** —— "自己和自己相关"没有 p 值可言,
|
|
||||||
写成 `0.0` 会被下游读成"极显著"。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError / ValueError:
|
|
||||||
同 `correlation_matrix`。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
每对变量都先**成对删缺失**再算,与 `DataFrame.corr` 的口径一致 ——
|
|
||||||
否则 r 和 p 会基于不同的样本量,对不上;成对有效观测少于 `min_periods` 时给 NaN。
|
|
||||||
只算上三角再镜像,成对变量的 p 天然对称,省掉一半重复计算。
|
|
||||||
"""
|
|
||||||
df_num = _check_corr_args(data, method)
|
|
||||||
corr = df_num.corr(method=method, min_periods=min_periods)
|
|
||||||
|
|
||||||
p_values = pd.DataFrame(np.nan, index=corr.index, columns=corr.columns, dtype=float)
|
|
||||||
for i in range(len(corr.columns)):
|
|
||||||
for j in range(i + 1, len(corr.columns)):
|
|
||||||
pair = df_num.iloc[:, [i, j]].dropna()
|
|
||||||
if len(pair) < min_periods:
|
|
||||||
continue
|
|
||||||
x = pair.iloc[:, 0]
|
|
||||||
y = pair.iloc[:, 1]
|
|
||||||
if method == "pearson":
|
|
||||||
p = _pvalue(stats.pearsonr(x, y))
|
|
||||||
elif method == "kendall":
|
|
||||||
p = _pvalue(stats.kendalltau(x, y))
|
|
||||||
else:
|
|
||||||
p = _pvalue(stats.spearmanr(x, y))
|
|
||||||
p_values.iloc[i, j] = p
|
|
||||||
p_values.iloc[j, i] = p
|
|
||||||
return p_values
|
|
||||||
|
|
||||||
|
|
||||||
def _star(value: float, p: float) -> str:
|
|
||||||
"""把系数与 p 值拼成 `"0.812**"`。p 为 NaN(对角线 / 观测不足)时不加星标。"""
|
|
||||||
text = f"{value:.3f}"
|
|
||||||
if p < 0.001:
|
|
||||||
return text + "***"
|
|
||||||
if p < 0.01:
|
|
||||||
return text + "**"
|
|
||||||
if p < 0.05:
|
|
||||||
return text + "*"
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def correlation_table(
|
|
||||||
data: Any,
|
|
||||||
method: Literal["pearson", "kendall", "spearman"] = "pearson",
|
|
||||||
min_periods: int = 1,
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""可直接贴论文的相关性表:**系数 + 显著性星标**。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
data / method / min_periods:
|
|
||||||
含义与 `correlation_matrix` 完全一致。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
pandas.DataFrame
|
|
||||||
每个格子是字符串,形如 `"0.812**"`。
|
|
||||||
星标:`***` p < 0.001、`**` p < 0.01、`*` p < 0.05,其余不带星。
|
|
||||||
**对角线只显示 `1.000` 不带星标** —— 自己和自己没有显著性可言。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError / ValueError:
|
|
||||||
同 `correlation_matrix`。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
这张表**只能看**:格子里是字符串,做不了后续计算。
|
|
||||||
要数值请用 `correlation_matrix`(系数)与 `correlation_pvalues`(p 值)。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> print(correlation_table(df)) # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
corr = correlation_matrix(data, method=method, min_periods=min_periods)
|
|
||||||
p_values = correlation_pvalues(data, method=method, min_periods=min_periods)
|
|
||||||
|
|
||||||
table = corr.astype(str)
|
|
||||||
for i in range(len(corr)):
|
|
||||||
for j in range(len(corr.columns)):
|
|
||||||
table.iloc[i, j] = _star(corr.iloc[i, j], p_values.iloc[i, j])
|
|
||||||
return table
|
|
||||||
|
|
||||||
|
|
||||||
def confidence_interval(
|
|
||||||
x: Any,
|
|
||||||
confidence: float = 0.95,
|
|
||||||
method: str = "normal",
|
|
||||||
) -> tuple[float, float]:
|
|
||||||
"""均值或比例的置信区间。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
x:
|
|
||||||
样本。`list` / `tuple` / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`;
|
|
||||||
传 DataFrame 时**所有数值列会被摊平成一个样本**,非数值列跳过。
|
|
||||||
confidence:
|
|
||||||
置信水平,取 `(0, 1)` 开区间,默认 0.95。
|
|
||||||
method:
|
|
||||||
`"normal"`:`mean ± z·s/√n`(n 大时用);
|
|
||||||
`"t"`:小样本用 t 分布(更保守,n < 30 建议用这个);
|
|
||||||
`"proportion"`:比例的标准误 `√(p(1-p)/n)`,结果会被夹到 `[0, 1]`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
tuple[float, float]
|
|
||||||
`(下限, 上限)`,双侧区间。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`x` 不是受支持的容器类型,或里面的元素转不成数值。
|
|
||||||
ValueError
|
|
||||||
`x` 里没有可用数值;`confidence` 不在 `(0, 1)` 内;
|
|
||||||
`method` 不在支持列表内;样本量不足 2(估不出标准差)。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
**只有 `proportion` 会把结果夹到 `[0, 1]`** —— 比例的区间不可能越界。
|
|
||||||
`normal` / `t` 是**均值**的区间,量纲上完全可以落在 `[0, 1]` 之外
|
|
||||||
(平均身高 170cm 的区间下界不可能是 0);夹一下只会给出**错的下界**。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> lo, hi = confidence_interval([1, 2, 3, 4, 5], confidence=0.95)
|
|
||||||
"""
|
|
||||||
# 入口统一走 as_array(容器类型/数值转换的错误口径只有一处);样本是"一串数",显式摊平。
|
|
||||||
arr, _ = as_array(x, numeric_only=True, ndim=1)
|
|
||||||
|
|
||||||
if method not in _VALID_CONFIDENCE_METHODS:
|
|
||||||
raise ValueError(f"不支持的方法:{method},仅支持{list(_VALID_CONFIDENCE_METHODS)}")
|
|
||||||
if not 0 < confidence < 1:
|
|
||||||
raise ValueError(f"confidence 必须在 (0, 1) 开区间内,而你传入了{confidence}")
|
|
||||||
|
|
||||||
arr = arr[~np.isnan(arr)]
|
|
||||||
n = len(arr)
|
|
||||||
if n < 2:
|
|
||||||
raise ValueError(f"样本量至少要有 2 个才估得出标准差,而你只有{n}个")
|
|
||||||
|
|
||||||
if method == "proportion":
|
|
||||||
p = float(np.mean(arr))
|
|
||||||
se = float(np.sqrt(p * (1 - p) / n))
|
|
||||||
z = float(stats.norm.ppf(1 - (1 - confidence) / 2))
|
|
||||||
return (max(0.0, p - z * se), min(1.0, p + z * se))
|
|
||||||
|
|
||||||
mean = float(np.mean(arr))
|
|
||||||
se = float(np.std(arr, ddof=1)) / np.sqrt(n)
|
|
||||||
if method == "normal":
|
|
||||||
half = float(stats.norm.ppf(1 - (1 - confidence) / 2)) * se
|
|
||||||
else:
|
|
||||||
half = float(stats.t.ppf(1 - (1 - confidence) / 2, df=n - 1)) * se
|
|
||||||
return (mean - half, mean + half)
|
|
||||||
|
|
||||||
def t_test(a: Any, b: Any | None = None, value: float = 0.0, equal_var: bool = False, paired: bool = False, alpha: float = 0.05) -> Any:
|
|
||||||
"""t 检验:两组均值有没有统计学差异。`b=None` 时是单样本检验。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
a:
|
|
||||||
第一组样本(`DataFrame` / `Series` / list / tuple / `ndarray`),缺失值自动剔除。
|
|
||||||
b:
|
|
||||||
第二组样本;`None` 表示单样本检验(对照 `value`)。
|
|
||||||
value:
|
|
||||||
单样本检验的原假设均值,`b is None` 时生效。
|
|
||||||
equal_var:
|
|
||||||
`True` 走学生 t(假设方差齐性),`False` 走 Welch(不假设,默认,更稳)。
|
|
||||||
paired:
|
|
||||||
配对样本检验;要求 `a` 与 `b` 等长。
|
|
||||||
alpha:
|
|
||||||
显著性水平,取 `(0, 1)` 开区间。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
dict
|
|
||||||
`{"statistic", "pvalue", "df", "significant", "conclusion"}`。
|
|
||||||
`conclusion` 是一句能直接抄进论文的中文结论。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
样本转不成数值数组。
|
|
||||||
ValueError
|
|
||||||
`alpha` 不在 `(0, 1)` 内;样本里没有数值列;样本量不足 2;
|
|
||||||
配对检验时两组长度不一致。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
传 `DataFrame` 时**所有数值列会被摊平成一个样本** —— 多列不同量纲时请先自己取列。
|
|
||||||
"""
|
|
||||||
if not (0 < alpha < 1):
|
|
||||||
raise ValueError(f"显著性水平 alpha 必须在 (0, 1) 之间,你传入了 {alpha}")
|
|
||||||
def _clean_data(data: Any) -> np.ndarray:
|
|
||||||
arr, _ = as_array(data, numeric_only=True, ndim=1)
|
|
||||||
return arr[~np.isnan(arr)]
|
|
||||||
|
|
||||||
a_arr = _clean_data(a)
|
|
||||||
|
|
||||||
if len(a_arr) < 2:
|
|
||||||
raise ValueError("样本 a 的数据量少于 2 个,无法进行 t 检验")
|
|
||||||
if b is None:
|
|
||||||
res = stats.ttest_1samp(a_arr, popmean=value)
|
|
||||||
df = len(a_arr) - 1
|
|
||||||
test_name = f"单样本 t 检验 (检验均值是否等于 {value})"
|
|
||||||
else:
|
|
||||||
b_arr = _clean_data(b)
|
|
||||||
if paired:
|
|
||||||
if len(a_arr) != len(b_arr):
|
|
||||||
raise ValueError("配对 t 检验要求两组数据长度一致")
|
|
||||||
res = stats.ttest_rel(a_arr, b_arr)
|
|
||||||
df = len(a_arr) - 1
|
|
||||||
test_name = "配对样本 t 检验"
|
|
||||||
else:
|
|
||||||
res = stats.ttest_ind(a_arr, b_arr, equal_var=equal_var)
|
|
||||||
if equal_var:
|
|
||||||
df = len(a_arr) + len(b_arr) - 2
|
|
||||||
test_name = "独立双样本 t 检验 (假设方差齐性)"
|
|
||||||
else:
|
|
||||||
v1, v2 = np.var(a_arr, ddof=1), np.var(b_arr, ddof=1)
|
|
||||||
n1, n2 = len(a_arr), len(b_arr)
|
|
||||||
df = (v1/n1 + v2/n2)**2 / ((v1/n1)**2/(n1-1) + (v2/n2)**2/(n2-1))
|
|
||||||
test_name = "Welch's 独立双样本 t 检验"
|
|
||||||
|
|
||||||
statistic = _statistic(res)
|
|
||||||
pvalue = _pvalue(res)
|
|
||||||
significant = bool(pvalue < alpha)
|
|
||||||
|
|
||||||
if significant:
|
|
||||||
conclusion = f"在 {alpha} 显著性水平下,{test_name} 结果显著 (p={pvalue:.4f} < {alpha}),拒绝原假设,认为两组数据存在统计学上的显著差异。"
|
|
||||||
else:
|
|
||||||
conclusion = f"在 {alpha} 显著性水平下,{test_name} 结果不显著 (p={pvalue:.4f} >= {alpha}),不能拒绝原假设,认为两组数据没有统计学上的显著差异。"
|
|
||||||
|
|
||||||
# 6. 返回字典
|
|
||||||
return {
|
|
||||||
"statistic": statistic,
|
|
||||||
"pvalue": pvalue,
|
|
||||||
"df": float(df),
|
|
||||||
"significant": significant,
|
|
||||||
"conclusion": conclusion
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def anova(*groups: Any, alpha: float = 0.05) -> Any:
|
|
||||||
"""单因素方差分析:多组均值是否相同。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
dict:`{"F", "pvalue", "conclusion"}`。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def chi_square_test(observed: Any, expected: Any | None = None, alpha: float = 0.05) -> Any:
|
|
||||||
"""卡方检验:观察频数与期望频数是否一致(分类变量的独立性 / 拟合优度)。"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def fit_distribution(x: Any, candidates: tuple[str, ...] = ("norm", "lognorm", "gamma", "expon", "uniform")) -> Any:
|
|
||||||
"""对候选分布逐个拟合,按 AIC(或 KS 统计量)排序。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
`Result`:`data` 是排好序的候选表;`params["best"]` 是最优分布名,
|
|
||||||
`extras["dist"]` 是可直接调 `.pdf / .cdf / .ppf / `.rvs` 的 scipy 分布对象。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
这是"反正态题"的标准做法:先看图 → 候选分布拟合 → 比较 AIC → 做 KS 检验。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> r = fit_distribution(data) # doctest: +SKIP
|
|
||||||
>>> r.params["best"] # doctest: +SKIP
|
|
||||||
'lognorm'
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def goodness_of_fit(x: Any, dist: str = "norm", method: str = "ks") -> Any:
|
|
||||||
"""拟合优度检验:`"ks"`(连续)/ `"chi2"`(分箱)。"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""viz —— 出图与出表(论文产出的最后一公里)。
|
|
||||||
|
|
||||||
原建议把 `visualization/` 拆成 5 个文件、13 个 `plot_*` 函数。
|
|
||||||
但每个 `plot_*` 本质上都是"在 ax 上加两行",拆开后
|
|
||||||
**改一次全局样式要改 13 处**,而且 13 个名字谁也记不住。
|
|
||||||
合并为 `plot(kind=...)` + 3 个有真实逻辑的专用图。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .export import save_figure, save_table
|
|
||||||
from .plot import plot, plot_convergence, plot_correlation_matrix, plot_residuals
|
|
||||||
from .style import mcm_style
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"mcm_style",
|
|
||||||
"plot",
|
|
||||||
"plot_correlation_matrix",
|
|
||||||
"plot_residuals",
|
|
||||||
"plot_convergence",
|
|
||||||
"save_figure",
|
|
||||||
"save_table",
|
|
||||||
]
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
"""图表导出。
|
|
||||||
|
|
||||||
工具箱的最终消费者是**论文**,所以这一层不是"附赠功能",
|
|
||||||
而是原建议里最被低估的部分(它只给了一句 `save_figure`)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from matplotlib.figure import Figure
|
|
||||||
|
|
||||||
__all__ = ["save_figure", "save_table"]
|
|
||||||
|
|
||||||
_ALLOWED_FORMATS = frozenset({"png", "pdf", "svg", "eps", "ps", "jpg", "jpeg", "tif", "tiff", "webp"})
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_formats(formats: Any) -> tuple[str, ...]:
|
|
||||||
"""校验并规范化输出格式;重复、空列表、单个字符串都在入口拦下。"""
|
|
||||||
if isinstance(formats, str):
|
|
||||||
raise TypeError("formats 必须是 tuple / list,不要传单个字符串(会把 'png' 拆成 p/n/g)")
|
|
||||||
if not isinstance(formats, (tuple, list)):
|
|
||||||
raise TypeError(f"formats 必须是 tuple / list,收到 {type(formats).__name__}")
|
|
||||||
if not formats:
|
|
||||||
raise ValueError("formats 不能为空")
|
|
||||||
|
|
||||||
normalized: list[str] = []
|
|
||||||
for fmt in formats:
|
|
||||||
if not isinstance(fmt, str):
|
|
||||||
raise TypeError(f"formats 里的每一项都必须是字符串,收到 {type(fmt).__name__}")
|
|
||||||
key = fmt.lower().lstrip(".")
|
|
||||||
if key not in _ALLOWED_FORMATS:
|
|
||||||
allowed = ", ".join(sorted(_ALLOWED_FORMATS))
|
|
||||||
raise ValueError(f"不支持的图片格式:{fmt!r};可选:{allowed}")
|
|
||||||
if key in normalized:
|
|
||||||
raise ValueError(f"formats 里有重复的格式:{key!r}")
|
|
||||||
normalized.append(key)
|
|
||||||
return tuple(normalized)
|
|
||||||
|
|
||||||
|
|
||||||
def save_figure(
|
|
||||||
fig: Any,
|
|
||||||
path: str | Path,
|
|
||||||
dpi: int | np.integer = 300,
|
|
||||||
formats: tuple[str, ...] | list[str] = ("png", "pdf"),
|
|
||||||
bbox_inches: str | None = "tight",
|
|
||||||
transparent: bool = False,
|
|
||||||
) -> list[Path]:
|
|
||||||
"""保存图,默认同时出 PNG(预览用)和 PDF(LaTeX 插图用)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
fig:
|
|
||||||
`matplotlib.figure.Figure`(`plt.subplots()` 的第一个返回值)。
|
|
||||||
path:
|
|
||||||
文件名**主干**。若带了 `png` / `pdf` 等图片后缀,会先去掉后缀,
|
|
||||||
再按 `formats` 逐个生成;父目录不存在时自动创建。
|
|
||||||
dpi:
|
|
||||||
输出分辨率,默认 300 —— 美赛送稿的 DPI 底线。
|
|
||||||
formats:
|
|
||||||
要写的格式,tuple / list,大小写不敏感,顺序决定返回顺序。
|
|
||||||
支持 png / pdf / svg / eps / ps / jpg / jpeg / tif / tiff / webp。
|
|
||||||
bbox_inches:
|
|
||||||
传给 `Figure.savefig`;默认 `"tight"` 去掉多余白边,`None` 用 matplotlib 默认值。
|
|
||||||
transparent:
|
|
||||||
是否透明背景。正文用图一般保持 `False`,叠加图例或封面图可以用 `True`。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
实际写出的文件路径列表,顺序与 `formats` 一致。
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
TypeError
|
|
||||||
`fig` 不是 `Figure`;`formats` 不是 tuple / list、含非字符串项;
|
|
||||||
`dpi` / `transparent` / `bbox_inches` 类型不对。
|
|
||||||
ValueError
|
|
||||||
`formats` 为空、含不支持的格式或重复格式;`dpi <= 0`;
|
|
||||||
`path` 为空或是一个目录。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
所有校验都在建目录、写文件**之前**完成,传错格式不会留下空目录。
|
|
||||||
建议配合项目的 figure 目录约定:`figures/fig{序号}_{短名}.pdf`,
|
|
||||||
图号与正文引用一一对应,最后核对时不容易错位。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> fig, ax = plt.subplots() # doctest: +SKIP
|
|
||||||
>>> paths = save_figure(fig, "figures/fig1")
|
|
||||||
>>> [p.suffix for p in paths]
|
|
||||||
['.png', '.pdf']
|
|
||||||
"""
|
|
||||||
if not isinstance(fig, Figure):
|
|
||||||
raise TypeError(f"fig 必须是 matplotlib 的 Figure,收到 {type(fig).__name__}")
|
|
||||||
|
|
||||||
if isinstance(dpi, bool) or not isinstance(dpi, (int, np.integer)):
|
|
||||||
raise TypeError(f"dpi 必须是正整数,收到 {type(dpi).__name__}")
|
|
||||||
if dpi <= 0:
|
|
||||||
raise ValueError(f"dpi 必须大于 0,收到 {dpi}")
|
|
||||||
|
|
||||||
if not isinstance(transparent, bool):
|
|
||||||
raise TypeError(f"transparent 必须是 bool,收到 {type(transparent).__name__}")
|
|
||||||
|
|
||||||
if bbox_inches is not None:
|
|
||||||
if not isinstance(bbox_inches, str):
|
|
||||||
raise TypeError(f"bbox_inches 必须是字符串或 None,收到 {type(bbox_inches).__name__}")
|
|
||||||
if not bbox_inches.strip():
|
|
||||||
raise ValueError("bbox_inches 不能是空字符串")
|
|
||||||
|
|
||||||
if not isinstance(path, (str, Path)):
|
|
||||||
raise TypeError(f"path 必须是 str 或 Path,收到 {type(path).__name__}")
|
|
||||||
base = Path(path)
|
|
||||||
if not base.name:
|
|
||||||
raise ValueError(f"path 不能为空:{path!r}")
|
|
||||||
if base.is_dir():
|
|
||||||
raise ValueError(f"path 是一个目录,请给文件名主干:{base}")
|
|
||||||
if base.suffix and base.suffix.lower().lstrip(".") in _ALLOWED_FORMATS:
|
|
||||||
base = base.with_suffix("")
|
|
||||||
|
|
||||||
normalized = _normalize_formats(formats)
|
|
||||||
base.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
written: list[Path] = []
|
|
||||||
for fmt in normalized:
|
|
||||||
target = base.with_suffix(f".{fmt}")
|
|
||||||
fig.savefig(
|
|
||||||
target,
|
|
||||||
dpi=int(dpi),
|
|
||||||
bbox_inches=bbox_inches,
|
|
||||||
transparent=transparent,
|
|
||||||
format=fmt,
|
|
||||||
)
|
|
||||||
written.append(target)
|
|
||||||
return written
|
|
||||||
|
|
||||||
|
|
||||||
def save_table(
|
|
||||||
df: Any,
|
|
||||||
path: str | Path,
|
|
||||||
fmt: tuple[str, ...] = ("csv", "latex", "markdown"),
|
|
||||||
caption: str | None = None,
|
|
||||||
label: str | None = None,
|
|
||||||
float_format: str = "%.4f",
|
|
||||||
index: bool = True,
|
|
||||||
) -> list[Path]:
|
|
||||||
r"""导出结果表,默认同时出 CSV + LaTeX + Markdown。
|
|
||||||
|
|
||||||
这是**节省时间最多的一个函数**:`compare_models`、`parameter_sweep`、
|
|
||||||
`topsis` 的结果都能直接 `save_table(r.data, "tab3_model_compare")`,
|
|
||||||
然后把 LaTeX 片段粘进论文,不用手工敲表格。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
caption / label:
|
|
||||||
写进 LaTeX 的 `\caption{}` 与 `\label{}`,
|
|
||||||
表号与正文 `\ref{}` 的对应关系就不会断。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
实际写出的文件路径列表。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> paths = save_table(df, "tables/tab2", caption="模型对比") # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""绘图。
|
|
||||||
|
|
||||||
`plot` 是一个通用入口,按 `kind` 分派;三个专用图有真实的额外逻辑
|
|
||||||
(显著性星标、残差诊断、收敛曲线),所以单独保留。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = ["plot", "plot_convergence", "plot_correlation_matrix", "plot_residuals"]
|
|
||||||
|
|
||||||
|
|
||||||
def plot(
|
|
||||||
x: Any = None,
|
|
||||||
y: Any = None,
|
|
||||||
kind: str = "line",
|
|
||||||
ax: Any = None,
|
|
||||||
labels: Any = None,
|
|
||||||
title: str | None = None,
|
|
||||||
xlabel: str | None = None,
|
|
||||||
ylabel: str | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> Any:
|
|
||||||
"""通用绘图入口。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
kind:
|
|
||||||
`"line"` / `"scatter"` / `"hist"` / `"box"` / `"bar"` / `"heatmap"` / `"pie"`。
|
|
||||||
ax:
|
|
||||||
传入已有 axes 就在上面画(多子图场景);`None` 时新建 figure。
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
符合"创建—绘制—保存"三段的 `(fig, ax)`,交给 :func:`save_figure`。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> fig, ax = plot([1, 2, 3], [1, 4, 9], kind="line") # doctest: +SKIP
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def plot_correlation_matrix(data: Any, method: str = "pearson", annot: bool = True, significance: bool = True) -> Any:
|
|
||||||
"""相关矩阵热图。
|
|
||||||
|
|
||||||
`significance=True` 时在不显著的格子(`p > 0.05`)上打叉。
|
|
||||||
这比只画颜色有意义得多——**不显著的相关性画出来是误导**。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def plot_residuals(y_true: Any, y_pred: Any, n_features: int | None = None) -> Any:
|
|
||||||
"""残差诊断四联图:残差-拟合值、Q-Q 图、直方图、残差-序。
|
|
||||||
|
|
||||||
拟合完模型**第一件事就是看这个**:
|
|
||||||
* 残差-拟合值有漏斗形 → 异方差,考虑加权最小二乘或对数变换;
|
|
||||||
* Q-Q 图偏离直线 → 正态性假设不成立,区间估计要改用 bootstrap;
|
|
||||||
* 残差有周期性 → 漏了时间/结构项。
|
|
||||||
|
|
||||||
这三个诊断结论是论文"模型检验"一节的素材,比只报一个 R² 有说服力。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
def plot_convergence(history: Any, ax: Any = None, labels: Any = None, log: bool = False, reference: float | None = None) -> Any:
|
|
||||||
"""收敛曲线。
|
|
||||||
|
|
||||||
`history` 可以是迭代轨迹数组、也接受 `Result.extras["history"]`
|
|
||||||
(`simulated_annealing` 与 `monte_carlo` 都会填这个字段)。
|
|
||||||
`reference` 传入解析解时会把真值画成虚线,
|
|
||||||
用于"MC 积分误差随 n 收敛"这类图——这是证明 `n` 选得合理的标准做法。
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""统一图样式。
|
|
||||||
|
|
||||||
美赛送稿要求:**双栏排版、字号不能太小、图必须能在黑白打印下区分**。
|
|
||||||
把这些要求固化成一次调用,比每次手调 rcParams 靠谱。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
__all__ = ["mcm_style"]
|
|
||||||
|
|
||||||
|
|
||||||
def mcm_style(palette: str = "colorblind", dpi: int = 300, font_size: float = 9.0, **overrides: Any) -> Any:
|
|
||||||
"""设置全局绘图样式,返回 matplotlib rcParams(便于 `with` 临时切换)。
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
palette:
|
|
||||||
`"colorblind"`(默认,色盲友好且黑白打印可辨)/ `"muted"` / `"deep"`。
|
|
||||||
dpi:
|
|
||||||
默认 300 —— 美赛送稿的 DPI 底线,低于这个值的图放大后会糊。
|
|
||||||
font_size:
|
|
||||||
默认 9pt。**双栏排版下字号小于 8pt 就会被评审认为是"塞图"**。
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
* 中文标签需要设置中文字体(`SimHei` / `Microsoft YaHei`),
|
|
||||||
否则 matplotlib 会画一堆方块;本函数会尝试自动选择可用中文字体。
|
|
||||||
* 论文用图建议**只用线型 + 标记区分**,不要只靠颜色。
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> import matplotlib
|
|
||||||
>>> mcm_style()
|
|
||||||
>>> matplotlib.rcParams["figure.dpi"]
|
|
||||||
300.0
|
|
||||||
"""
|
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
|
||||||
Whitespace-only changes.
@@ -1,32 +0,0 @@
|
|||||||
"""pytest 公共夹具。"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def rng():
|
|
||||||
"""固定种子的随机数生成器——所有含随机的测试都必须用它,保证可复现。"""
|
|
||||||
return np.random.default_rng(20260101)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def linear_data(rng):
|
|
||||||
"""带噪声的线性数据,用于回归类测试(真值 y = 2x + 1)。"""
|
|
||||||
x = rng.uniform(-5, 5, size=200)
|
|
||||||
y = 2.0 * x + 1.0 + rng.normal(0, 0.1, size=200)
|
|
||||||
return x, y
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def decision_matrix():
|
|
||||||
"""3 个方案 × 4 个指标的决策矩阵,用于评价类测试。"""
|
|
||||||
return np.array(
|
|
||||||
[
|
|
||||||
[9.0, 0.3, 120.0, 8.5],
|
|
||||||
[7.5, 0.5, 90.0, 9.0],
|
|
||||||
[8.8, 0.2, 150.0, 7.0],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""公开 API 契约测试。
|
|
||||||
|
|
||||||
这组测试**不测算法本身**,只守两件事:
|
|
||||||
1. `__all__` 里承诺的名字,必须真的存在且可调用;
|
|
||||||
2. 子包的导出必须与顶层一致。
|
|
||||||
|
|
||||||
它现在就能通过,并且在后续实现过程中会立刻抓到"改了导出忘了改 `__all__`"这类问题。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import mcm_toolkit as mcm
|
|
||||||
|
|
||||||
SUBPACKAGES = ["core", "data", "stats", "models", "solve", "decision", "viz"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_version_is_string():
|
|
||||||
assert isinstance(mcm.__version__, str)
|
|
||||||
assert mcm.__version__.count(".") == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", SUBPACKAGES)
|
|
||||||
def test_subpackage_importable(name):
|
|
||||||
mod = importlib.import_module(f"mcm_toolkit.{name}")
|
|
||||||
assert hasattr(mod, "__all__"), f"mcm_toolkit.{name} 缺 __all__"
|
|
||||||
assert hasattr(mcm, name), f"顶层未导出子包 {name}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", SUBPACKAGES)
|
|
||||||
def test_subpackage_all_resolves(name):
|
|
||||||
mod = importlib.import_module(f"mcm_toolkit.{name}")
|
|
||||||
missing = [n for n in mod.__all__ if not hasattr(mod, n)]
|
|
||||||
assert not missing, f"mcm_toolkit.{name}.__all__ 里有不存在的名字:{missing}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_toplevel_all_resolves():
|
|
||||||
missing = [n for n in mcm.__all__ if not hasattr(mcm, n)]
|
|
||||||
assert not missing, f"顶层 __all__ 里有不存在的名字:{missing}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_toplevel_shortcuts_are_callable():
|
|
||||||
for name in ["monte_carlo", "bootstrap_ci", "parameter_sweep", "topsis", "compare_models"]:
|
|
||||||
assert callable(getattr(mcm, name)), f"{name} 应当是可调用的快捷函数"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_star_import_pollution():
|
|
||||||
"""顶层不应把子包内部的函数全部摊平——只允许 __all__ 里那批。"""
|
|
||||||
allowed = set(mcm.__all__) | {"annotations"} # annotations 来自 __future__ 导入
|
|
||||||
leaked = [n for n in dir(mcm) if not n.startswith("_") and n not in allowed]
|
|
||||||
assert not leaked, f"顶层出现了 __all__ 之外的公开名字:{leaked}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_result_dataclass_defaults():
|
|
||||||
r = mcm.Result("demo")
|
|
||||||
assert r.success is True
|
|
||||||
assert r.params is None
|
|
||||||
assert r.metrics is None
|
|
||||||
assert r.message == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_helpers_exist():
|
|
||||||
assert callable(mcm.set_seed)
|
|
||||||
assert callable(mcm.get_rng)
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
"""core 包测试。
|
|
||||||
|
|
||||||
TODO(L0):TestValidate 的 check_* 实现后去掉 skip 标记(Result / Random / as_array 已完成)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
class TestRandom:
|
|
||||||
def test_get_rng_is_reproducible(self):
|
|
||||||
from mcm_toolkit.core import get_rng
|
|
||||||
|
|
||||||
assert get_rng(42).random() == get_rng(42).random()
|
|
||||||
|
|
||||||
def test_get_rng_passes_through_generator(self):
|
|
||||||
from mcm_toolkit.core import get_rng
|
|
||||||
|
|
||||||
g = np.random.default_rng(0)
|
|
||||||
assert get_rng(g) is g
|
|
||||||
|
|
||||||
def test_get_rng_accepts_numpy_integer(self):
|
|
||||||
"""np.int64 不是 int 的子类,必须当成整数处理,不能静默退化成全局流。"""
|
|
||||||
from mcm_toolkit.core import get_rng, set_seed
|
|
||||||
|
|
||||||
set_seed(1)
|
|
||||||
first = get_rng(np.int64(42))
|
|
||||||
second = get_rng(np.int64(42))
|
|
||||||
assert first is not second, "传 np.integer 应当新建独立生成器"
|
|
||||||
assert first.random() == np.random.default_rng(42).random()
|
|
||||||
|
|
||||||
def test_get_rng_rejects_bad_types(self):
|
|
||||||
"""类型不对就报错。静默当成"没传种子",换来的只会是不可复现的结果。"""
|
|
||||||
from mcm_toolkit.core import get_rng
|
|
||||||
|
|
||||||
for bad in ["42", 42.0, True, [42]]:
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
get_rng(bad)
|
|
||||||
|
|
||||||
def test_get_rng_without_seed_shares_and_advances(self):
|
|
||||||
from mcm_toolkit.core import get_rng, set_seed
|
|
||||||
|
|
||||||
set_seed(3)
|
|
||||||
assert get_rng() is get_rng(), "无参调用应返回同一个共享生成器"
|
|
||||||
assert get_rng().random() != get_rng().random(), "共享生成器必须被推进"
|
|
||||||
|
|
||||||
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)
|
|
||||||
first = np.random.random() # noqa: NPY002 - 这里就是要验证旧全局被设上了
|
|
||||||
set_seed(7)
|
|
||||||
second = np.random.random() # noqa: NPY002
|
|
||||||
assert first == second
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0")
|
|
||||||
class TestValidate:
|
|
||||||
def test_check_same_length_raises_with_detail(self):
|
|
||||||
from mcm_toolkit.core import check_same_length
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=r"2.*3|长度"):
|
|
||||||
check_same_length([1, 2], [1, 2, 3])
|
|
||||||
|
|
||||||
def test_check_array_rejects_nan(self):
|
|
||||||
from mcm_toolkit.core import check_array
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
check_array([1.0, np.nan, 2.0], finite=True)
|
|
||||||
|
|
||||||
def test_check_probability_bounds(self):
|
|
||||||
from mcm_toolkit.core import check_probability
|
|
||||||
|
|
||||||
assert check_probability(0.5) == 0.5
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
check_probability(1.5)
|
|
||||||
|
|
||||||
def test_check_square_matrix(self):
|
|
||||||
from mcm_toolkit.core import check_square_matrix
|
|
||||||
|
|
||||||
check_square_matrix([[1, 2], [3, 4]])
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
check_square_matrix([[1, 2, 3], [4, 5, 6]])
|
|
||||||
|
|
||||||
|
|
||||||
class TestAsArray:
|
|
||||||
"""`as_array` 是所有收数值函数的统一入口:白名单 + 转 ndarray + 报告原类型。"""
|
|
||||||
|
|
||||||
def test_reports_kind_for_every_supported_container(self):
|
|
||||||
from mcm_toolkit.core import as_array
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, 2.0], "city": ["X", "Y"]})
|
|
||||||
series = pd.Series([1.0, 2.0], index=["u", "v"], name="s")
|
|
||||||
|
|
||||||
assert as_array(df)[1] == "frame"
|
|
||||||
assert as_array(series)[1] == "series"
|
|
||||||
for raw in (np.array([1.0, 2.0]), [1.0, 2.0], (1.0, 2.0)):
|
|
||||||
assert as_array(raw)[1] == "array"
|
|
||||||
|
|
||||||
def test_frame_keeps_only_numeric_columns_by_default(self):
|
|
||||||
# numeric_only 默认 True:一列数值都没有就报 ValueError(与 describe / zscore 同口径)
|
|
||||||
from mcm_toolkit.core import as_array
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0], "city": ["X", "Y"]})
|
|
||||||
arr, kind = as_array(df)
|
|
||||||
|
|
||||||
assert arr.shape == (2, 2) and kind == "frame"
|
|
||||||
assert list(df.select_dtypes(include=[np.number]).columns) == ["a", "b"]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="数值"):
|
|
||||||
as_array(pd.DataFrame({"city": ["X", "Y"]}))
|
|
||||||
|
|
||||||
def test_frame_with_text_and_numeric_only_false_raises(self):
|
|
||||||
# 元素级函数(normalize / standardize / log_transform)走 numeric_only=False,
|
|
||||||
# 因为跳过列会让返回的表少列、与输入对不上;转不动就该报错,不能静默丢列。
|
|
||||||
from mcm_toolkit.core import as_array
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, 2.0], "city": ["X", "Y"]})
|
|
||||||
with pytest.raises(TypeError, match="转不成"):
|
|
||||||
as_array(df, numeric_only=False)
|
|
||||||
|
|
||||||
def test_default_never_flattens(self):
|
|
||||||
# 摊平曾是 t_test(DataFrame) 静默混合多列的根源,必须是显式行为
|
|
||||||
from mcm_toolkit.core import as_array
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, 2.0, 3.0], "b": [4.0, 5.0, 6.0]})
|
|
||||||
assert as_array(df)[0].shape == (3, 2)
|
|
||||||
assert as_array(df, ndim=1)[0].shape == (6,)
|
|
||||||
assert as_array(pd.Series([1.0, 2.0, 3.0]), ndim=1)[0].shape == (3,)
|
|
||||||
|
|
||||||
def test_ndim_2_checks_instead_of_reshaping(self):
|
|
||||||
# 一维数据是"1 个样本"还是"1 个特征"没有唯一答案,不能替调用方猜
|
|
||||||
from mcm_toolkit.core import as_array
|
|
||||||
|
|
||||||
assert as_array([[1.0, 2.0]], ndim=2)[0].shape == (1, 2)
|
|
||||||
with pytest.raises(ValueError, match="二维"):
|
|
||||||
as_array([1.0, 2.0], ndim=2)
|
|
||||||
with pytest.raises(ValueError, match="ndim"):
|
|
||||||
as_array([1.0, 2.0], ndim=3)
|
|
||||||
|
|
||||||
def test_rejects_types_outside_the_whitelist(self):
|
|
||||||
from mcm_toolkit.core import as_array
|
|
||||||
|
|
||||||
# str 也是可迭代对象:不显式挡住,"abc" 会被当成三个字符算下去
|
|
||||||
for bad in ("abc", {1, 2}, {"a": 1}, None, object()):
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
as_array(bad)
|
|
||||||
|
|
||||||
def test_keeps_missing_values(self):
|
|
||||||
# 删缺失是各函数的**语义**(corr 成对删、t_test 配对要成对删),不能在这里做
|
|
||||||
from mcm_toolkit.core import as_array
|
|
||||||
|
|
||||||
arr = as_array([1.0, np.nan, 3.0])[0]
|
|
||||||
assert arr.shape == (3,) and np.isnan(arr[1])
|
|
||||||
|
|
||||||
def test_dtype_none_keeps_original_dtype(self):
|
|
||||||
from mcm_toolkit.core import as_array
|
|
||||||
|
|
||||||
assert as_array([1, 2, 3], dtype=None)[0].dtype.kind == "i"
|
|
||||||
assert as_array([1, 2, 3])[0].dtype.kind == "f"
|
|
||||||
|
|
||||||
|
|
||||||
def test_result_repr_is_readable():
|
|
||||||
from mcm_toolkit.core import Result
|
|
||||||
|
|
||||||
text = repr(Result("demo", params={"a": 1}, metrics={"r2": 0.9}))
|
|
||||||
# 只断言关键信息在场,不断言完整字符串——否则以后调格式会让测试集体变红
|
|
||||||
assert "demo" in text and "r2" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_result_repr_stays_short_and_safe():
|
|
||||||
"""默认值、失败结果、十万级数组:repr 都不能崩,也不能刷屏。"""
|
|
||||||
from mcm_toolkit.core import Result
|
|
||||||
|
|
||||||
assert "empty" in repr(Result("empty"))
|
|
||||||
|
|
||||||
failed = repr(Result("boom", success=False, message="求解未收敛"))
|
|
||||||
assert "success=False" in failed
|
|
||||||
assert "求解未收敛" in failed
|
|
||||||
|
|
||||||
noisy = repr(Result("mc", data=np.arange(100_000)))
|
|
||||||
assert "ndarray(100000)" in noisy
|
|
||||||
assert len(noisy) < 200, "data 不应被整个打印出来"
|
|
||||||
|
|
||||||
|
|
||||||
def test_result_summary_is_markdown_table():
|
|
||||||
from mcm_toolkit.core import Result
|
|
||||||
|
|
||||||
result = Result("topsis", params={"n": 3}, metrics={"best": 0.83})
|
|
||||||
text = result.summary()
|
|
||||||
lines = text.splitlines()
|
|
||||||
|
|
||||||
assert lines[0] == "### topsis"
|
|
||||||
assert lines[2] == "| 项 | 值 |"
|
|
||||||
assert "| success | True |" in lines
|
|
||||||
assert "| params.n | 3 |" in lines
|
|
||||||
assert "| metrics.best | 0.83 |" in lines
|
|
||||||
assert result.summary() == text, "summary 必须无副作用、可重复调用"
|
|
||||||
|
|
||||||
|
|
||||||
def test_result_summary_appends_message_on_failure():
|
|
||||||
from mcm_toolkit.core import Result
|
|
||||||
|
|
||||||
text = Result("gm11", success=False, message="样本太少").summary()
|
|
||||||
assert "| success | False |" in text
|
|
||||||
assert text.rstrip().endswith("> 样本太少")
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
"""data 包测试。
|
|
||||||
|
|
||||||
L0 阶段的约定:**只测已实现的行为**。未实现的算法用逐条 skip 标出,
|
|
||||||
不要用模块级 pytestmark —— 那会把同文件里已实现的函数一起跳过,
|
|
||||||
让 load_table 这种已经写好的函数变成零覆盖。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L0/L1"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- io: load_table
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_table_detects_gbk(tmp_path):
|
|
||||||
"""国内赛题数据常见 GBK,编码探测必须能兜住。"""
|
|
||||||
from mcm_toolkit.data import load_table
|
|
||||||
|
|
||||||
p = tmp_path / "gbk.csv"
|
|
||||||
p.write_bytes("城市,人口\n北京,2189\n".encode("gbk"))
|
|
||||||
df = load_table(p)
|
|
||||||
assert list(df.columns) == ["城市", "人口"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_table_keeps_utf8_readable(tmp_path):
|
|
||||||
"""UTF-8 文件必须按 UTF-8 读出,不能先试 GBK 变成乱码。"""
|
|
||||||
from mcm_toolkit.data import load_table
|
|
||||||
|
|
||||||
p = tmp_path / "utf8.csv"
|
|
||||||
p.write_text("省份,GDP增速\n广东,5.8\n", encoding="utf-8")
|
|
||||||
df = load_table(p)
|
|
||||||
assert list(df.columns) == ["省份", "GDP增速"]
|
|
||||||
assert df.loc[0, "省份"] == "广东"
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_table_maps_na_like_tokens_to_nan(tmp_path):
|
|
||||||
"""赛题里的缺失值长得五花八门,默认 na_values 要全部吃成 NaN。"""
|
|
||||||
from mcm_toolkit.data import load_table
|
|
||||||
|
|
||||||
p = tmp_path / "na.csv"
|
|
||||||
p.write_text("a,b,c\n1,NA,\n2,-,#N/A\n", encoding="utf-8")
|
|
||||||
df = load_table(p)
|
|
||||||
# 外面套 bool():df["b"] 的类型是 Series | DataFrame,直接当条件用 pyright 会报
|
|
||||||
# "条件操作数无效";套上 bool() 既满足类型检查,又能在它真返回 Series 时当场炸掉。
|
|
||||||
assert bool(df["b"].isna().all())
|
|
||||||
assert bool(df["c"].isna().all())
|
|
||||||
assert bool(df["a"].notna().all())
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_table_respects_explicit_encoding(tmp_path):
|
|
||||||
"""显式给了 encoding 就照办,不再做探测。"""
|
|
||||||
from mcm_toolkit.data import load_table
|
|
||||||
|
|
||||||
p = tmp_path / "gbk.csv"
|
|
||||||
p.write_bytes("城市,人口\n上海,2487\n".encode("gbk"))
|
|
||||||
df = load_table(p, encoding="gbk")
|
|
||||||
assert list(df.columns) == ["城市", "人口"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_table_rejects_unknown_suffix(tmp_path):
|
|
||||||
"""不支持的扩展名要抛 ValueError,而且要带上后缀名。"""
|
|
||||||
from mcm_toolkit.data import load_table
|
|
||||||
|
|
||||||
p = tmp_path / "data.txt"
|
|
||||||
p.write_text("a,b\n1,2\n", encoding="utf-8")
|
|
||||||
with pytest.raises(ValueError, match=r"\.txt"):
|
|
||||||
load_table(p)
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_table_missing_file_raises(tmp_path):
|
|
||||||
"""文件不存在时要把路径原样带出来,方便定位。"""
|
|
||||||
from mcm_toolkit.data import load_table
|
|
||||||
|
|
||||||
missing = tmp_path / "nope.csv"
|
|
||||||
with pytest.raises(FileNotFoundError, match=r"nope\.csv"):
|
|
||||||
load_table(missing)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- io: save_table
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_table_round_trip(tmp_path):
|
|
||||||
"""存进去再读出来,内容要一致,并且返回实际写入的 Path。"""
|
|
||||||
from mcm_toolkit.data import load_table, save_table
|
|
||||||
|
|
||||||
df = pd.DataFrame({"城市": ["北京", "上海"], "人口": [2189.0, 2487.0]})
|
|
||||||
out = save_table(df, tmp_path / "out.csv")
|
|
||||||
assert isinstance(out, Path)
|
|
||||||
assert out.exists()
|
|
||||||
back = load_table(out)
|
|
||||||
assert list(back.columns) == ["城市", "人口"]
|
|
||||||
assert back["人口"].tolist() == [2189.0, 2487.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_table_rejects_non_dataframe(tmp_path):
|
|
||||||
"""传错类型要当场报错,不要写出一个奇怪的文件。"""
|
|
||||||
from mcm_toolkit.data import save_table
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
save_table([1, 2, 3], tmp_path / "bad.csv") # type: ignore[arg-type] # 就是要传错
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_table_validates_before_touching_disk(tmp_path):
|
|
||||||
"""校验必须早于副作用:参数不合法时,一个目录、一个文件都不能留下。"""
|
|
||||||
from mcm_toolkit.data import save_table
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=r"\.txt"):
|
|
||||||
save_table(pd.DataFrame({"a": [1]}), tmp_path / "d1" / "x.txt")
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
save_table([1, 2, 3], tmp_path / "d2" / "x.csv") # type: ignore[arg-type] # 就是要传错
|
|
||||||
|
|
||||||
assert sorted(p.name for p in tmp_path.iterdir()) == []
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- data: clean
|
|
||||||
|
|
||||||
|
|
||||||
def test_clean_missing_default_drops_any_missing():
|
|
||||||
"""不传 threshold 时是"只要有缺失就删"。"""
|
|
||||||
from mcm_toolkit.data import clean_missing
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, np.nan, 3.0], "b": [1.0, 2.0, np.nan]})
|
|
||||||
out = clean_missing(df)
|
|
||||||
assert len(out) == 1
|
|
||||||
assert list(out.columns) == ["a", "b"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_clean_missing_threshold_keeps_rows_within_limit():
|
|
||||||
"""四行三列、阈值 0.5:缺 2/3 的两行要删,剩 2 行。
|
|
||||||
|
|
||||||
3 列时 min_non_na 必须是 ceil(3*0.5)=2;写成 int() 截断就会漏删。
|
|
||||||
"""
|
|
||||||
from mcm_toolkit.data import clean_missing
|
|
||||||
|
|
||||||
df = pd.DataFrame(
|
|
||||||
{
|
|
||||||
"a": [1.0, np.nan, np.nan, 4.0],
|
|
||||||
"b": [1.0, np.nan, np.nan, 4.0],
|
|
||||||
"c": [1.0, 2.0, np.nan, 4.0],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
out = clean_missing(df, axis=0, threshold=0.5)
|
|
||||||
assert len(out) == 2
|
|
||||||
assert out["a"].tolist() == [1.0, 4.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_clean_missing_axis1_drops_empty_column():
|
|
||||||
"""axis=1 按列删:整列全空(缺失率 1.0 > 0.5)要掉。"""
|
|
||||||
from mcm_toolkit.data import clean_missing
|
|
||||||
|
|
||||||
df = pd.DataFrame({"x": [1.0, 2.0, 3.0], "y": [np.nan, np.nan, np.nan]})
|
|
||||||
assert list(clean_missing(df, axis=1, threshold=0.5).columns) == ["x"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_clean_missing_rejects_bad_axis():
|
|
||||||
from mcm_toolkit.data import clean_missing
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="axis"):
|
|
||||||
clean_missing(pd.DataFrame({"a": [1.0]}), axis=2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_clean_missing_rejects_non_dataframe():
|
|
||||||
from mcm_toolkit.data import clean_missing
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
clean_missing([[1.0, 2.0]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_impute_missing_mean_and_ffill():
|
|
||||||
from mcm_toolkit.data import impute_missing
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, np.nan, 3.0]})
|
|
||||||
assert impute_missing(df, method="mean")["a"].tolist() == [1.0, 2.0, 3.0]
|
|
||||||
assert impute_missing(df, method="ffill")["a"].tolist() == [1.0, 1.0, 3.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_impute_missing_mode_survives_all_nan_column():
|
|
||||||
"""整列全 NaN 时 mode() 给不出众数:不能崩,也不能硬填一个值进去。"""
|
|
||||||
from mcm_toolkit.data import impute_missing
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [np.nan, np.nan], "b": [1.0, np.nan]})
|
|
||||||
out = impute_missing(df, method="mode")
|
|
||||||
assert bool(out["a"].isna().all())
|
|
||||||
assert out["b"].tolist() == [1.0, 1.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_impute_missing_rejects_unknown_method():
|
|
||||||
from mcm_toolkit.data import impute_missing
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="bogus"):
|
|
||||||
impute_missing(pd.DataFrame({"a": [1.0]}), method="bogus")
|
|
||||||
|
|
||||||
|
|
||||||
def test_impute_missing_rejects_non_dataframe():
|
|
||||||
from mcm_toolkit.data import impute_missing
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
impute_missing([[1.0, 2.0]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_winsorize_clips_series_to_quantiles():
|
|
||||||
from mcm_toolkit.data import winsorize
|
|
||||||
|
|
||||||
s = pd.Series([1.0, 2.0, 3.0, 4.0, 1000.0])
|
|
||||||
out = winsorize(s, lower=0.1, upper=0.9)
|
|
||||||
assert float(out.iloc[0]) == pytest.approx(1.4)
|
|
||||||
assert float(out.iloc[-1]) == pytest.approx(601.6)
|
|
||||||
assert float(out.max()) < 1000.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_winsorize_rejects_bad_quantile_order():
|
|
||||||
from mcm_toolkit.data import winsorize
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="lower"):
|
|
||||||
winsorize(pd.Series([1.0, 2.0]), lower=0.9, upper=0.1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_winsorize_rejects_non_frame_with_typeerror():
|
|
||||||
"""§4.7:类型不对必须抛 TypeError,不能抛 ValueError。"""
|
|
||||||
from mcm_toolkit.data import winsorize
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
winsorize([1.0, 2.0, 3.0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_outliers_rejects_unknown_method():
|
|
||||||
"""方法名校验在取默认取值之前,所以这条现在就是绿的。"""
|
|
||||||
from mcm_toolkit.data import detect_outliers
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="bogus"):
|
|
||||||
detect_outliers([1.0, 2.0], method="bogus") # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_outliers_rejects_bad_type():
|
|
||||||
"""§4.7:类型不对必须抛 TypeError,不能让它一路飘进 pandas 里去。"""
|
|
||||||
from mcm_toolkit.data import detect_outliers
|
|
||||||
|
|
||||||
for bad in (42, 3.14, "123", None, {"a": [1, 2]}):
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
detect_outliers(bad)
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_outliers_survives_degenerate_columns():
|
|
||||||
"""全 NaN 列与单元素列都不能抛异常,判定一律为 False。"""
|
|
||||||
from mcm_toolkit.data import detect_outliers
|
|
||||||
|
|
||||||
df = pd.DataFrame({"all_nan": [np.nan, np.nan, np.nan], "one": [1.0, 2.0, 3.0]})
|
|
||||||
assert not bool(np.asarray(detect_outliers(df)).any())
|
|
||||||
assert not bool(np.asarray(detect_outliers([5.0])).any())
|
|
||||||
assert not bool(np.asarray(detect_outliers([5.0], method="zscore")).any())
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_outliers_accepts_tuple_as_1d():
|
|
||||||
"""元组按一维数组型处理,返回值与传 list 一致。"""
|
|
||||||
from mcm_toolkit.data import detect_outliers
|
|
||||||
|
|
||||||
mask = np.asarray(detect_outliers((1.0, 2.0, 3.0, 4.0, 1000.0)))
|
|
||||||
assert mask.shape == (5,)
|
|
||||||
assert mask.dtype == bool
|
|
||||||
assert bool(mask[-1])
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------ 曾经 xfail 的契约,现已全部实现(留作回归)
|
|
||||||
#
|
|
||||||
# 这些用例写的是**文档里已经承诺的行为**。实现没跟上时用 xfail,让它们照样出现在
|
|
||||||
# 报告里(显示成 x);修好之后会自动变成 XPASS —— 看到 XPASS 就把 mark 摘掉。
|
|
||||||
#
|
|
||||||
# 已摘掉:threshold 范围校验、IQR 的 UnboundLocalError、(n,1) 形状、zscore 默认阈值、
|
|
||||||
# winsorize 的文本列、detect_outliers 的文本列、impute_missing 的 mode 就地修改。
|
|
||||||
# **当前 0 条 xfail** —— 以后新增契约照旧:先写测试 + xfail,实现跟上就摘 mark。
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_outliers_finds_planted_points():
|
|
||||||
"""人工埋 3 个离群点,IQR 法应正好命中 3 个。"""
|
|
||||||
from mcm_toolkit.data import detect_outliers
|
|
||||||
|
|
||||||
x = np.concatenate([np.arange(100.0), [1e6, -1e6, 1e7]])
|
|
||||||
mask = np.asarray(detect_outliers(x, method="iqr"))
|
|
||||||
assert int(mask.sum()) == 3
|
|
||||||
assert bool(mask[-3:].all())
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_outliers_returns_mask_not_filtered_data():
|
|
||||||
"""约定:只标不删——返回与原数据同形的布尔掩码。"""
|
|
||||||
from mcm_toolkit.data import detect_outliers
|
|
||||||
|
|
||||||
x = np.arange(50.0)
|
|
||||||
mask = np.asarray(detect_outliers(x, method="zscore"))
|
|
||||||
assert mask.shape == x.shape
|
|
||||||
assert mask.dtype == bool
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_outliers_zscore_flags_planted_point():
|
|
||||||
from mcm_toolkit.data import detect_outliers
|
|
||||||
|
|
||||||
x = np.concatenate([np.zeros(99), [1e6]])
|
|
||||||
mask = np.asarray(detect_outliers(x, method="zscore"))
|
|
||||||
assert int(mask.sum()) == 1
|
|
||||||
assert bool(mask[-1].all())
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_outliers_survives_text_columns():
|
|
||||||
"""赛题表几乎都带文本列:分位数只在数值列上算,文本列恒 False。"""
|
|
||||||
from mcm_toolkit.data import detect_outliers
|
|
||||||
|
|
||||||
df = pd.DataFrame({"city": ["a", "b", "c", "d"], "v": [1.0, 2.0, 3.0, 1000.0]})
|
|
||||||
mask = detect_outliers(df)
|
|
||||||
assert int(np.asarray(mask).sum()) == 1
|
|
||||||
assert not bool(np.asarray(mask["city"]).any())
|
|
||||||
assert bool(np.asarray(mask["v"]).any())
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_outliers_all_text_frame_returns_all_false():
|
|
||||||
"""整表没有数值列时不报错,返回全 False 掩码(语义:这一列没有离群点)。"""
|
|
||||||
from mcm_toolkit.data import detect_outliers
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": ["x", "y"], "b": ["z", "w"]})
|
|
||||||
mask = detect_outliers(df)
|
|
||||||
assert mask.shape == df.shape
|
|
||||||
assert not bool(np.asarray(mask).any())
|
|
||||||
|
|
||||||
|
|
||||||
def test_winsorize_survives_text_columns():
|
|
||||||
"""文本列必须原样留着:分位数只在数值列上算。"""
|
|
||||||
from mcm_toolkit.data import winsorize
|
|
||||||
|
|
||||||
df = pd.DataFrame({"city": ["a", "b", "c", "d"], "v": [1.0, 2.0, 3.0, 1000.0]})
|
|
||||||
out = winsorize(df)
|
|
||||||
assert out["city"].tolist() == ["a", "b", "c", "d"]
|
|
||||||
assert float(out["v"].iloc[-1]) < 1000.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_clean_missing_rejects_out_of_range_threshold():
|
|
||||||
from mcm_toolkit.data import clean_missing
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="threshold"):
|
|
||||||
clean_missing(pd.DataFrame({"a": [1.0, np.nan]}), threshold=1.5)
|
|
||||||
|
|
||||||
|
|
||||||
def test_impute_missing_mode_does_not_mutate_input():
|
|
||||||
"""mode 分支也必须返回新表:不能顺手把调用方的表改了。"""
|
|
||||||
from mcm_toolkit.data import impute_missing
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, np.nan, 1.0]})
|
|
||||||
out = impute_missing(df, method="mode")
|
|
||||||
assert out["a"].tolist() == [1.0, 1.0, 1.0]
|
|
||||||
assert bool(df["a"].isna().any())
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------ data: transform
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_minmax_range(rng):
|
|
||||||
"""minmax 是**按列**压到 [0, 1],不是整表压一次。"""
|
|
||||||
from mcm_toolkit.data import normalize
|
|
||||||
|
|
||||||
x = rng.normal(size=(50, 3))
|
|
||||||
out = np.asarray(normalize(x, method="minmax"))
|
|
||||||
assert np.allclose(out.min(axis=0), 0.0)
|
|
||||||
assert np.allclose(out.max(axis=0), 1.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_minmax_doctest_values():
|
|
||||||
"""docstring 里那个 Examples 就是规格,不能只有形似。"""
|
|
||||||
from mcm_toolkit.data import normalize
|
|
||||||
|
|
||||||
assert normalize([1, 2, 3]).tolist() == [0.0, 0.5, 1.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_respects_feature_range():
|
|
||||||
from mcm_toolkit.data import normalize
|
|
||||||
|
|
||||||
out = np.asarray(normalize([1.0, 2.0, 3.0], feature_range=(-1.0, 1.0)))
|
|
||||||
assert np.allclose(out, [-1.0, 0.0, 1.0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_constant_column_stays_finite():
|
|
||||||
"""常量列 max == min,不能出 nan / inf;固定压到 feature_range 的下界。"""
|
|
||||||
from mcm_toolkit.data import normalize
|
|
||||||
|
|
||||||
out = np.asarray(normalize([[2.0, 1.0], [2.0, 3.0], [2.0, 5.0]]))
|
|
||||||
assert np.allclose(out[:, 0], 0.0)
|
|
||||||
assert bool(np.isfinite(out).all())
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_vector_method_gives_unit_columns():
|
|
||||||
from mcm_toolkit.data import normalize
|
|
||||||
|
|
||||||
out = np.asarray(normalize([[3.0, 0.0], [4.0, 0.0]], method="vector"))
|
|
||||||
assert np.allclose(np.linalg.norm(out, axis=0), [1.0, 0.0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_max_method_scales_by_column_max():
|
|
||||||
from mcm_toolkit.data import normalize
|
|
||||||
|
|
||||||
out = np.asarray(normalize([[1.0, 10.0], [2.0, 20.0]], method="max"))
|
|
||||||
assert np.allclose(out, [[0.5, 0.5], [1.0, 1.0]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_rejects_bad_method_and_feature_range():
|
|
||||||
from mcm_toolkit.data import normalize
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="bogus"):
|
|
||||||
normalize([1.0, 2.0], method="bogus")
|
|
||||||
with pytest.raises(ValueError, match="feature_range"):
|
|
||||||
normalize([1.0, 2.0], feature_range=(1.0, 0.0))
|
|
||||||
with pytest.raises(TypeError, match="feature_range"):
|
|
||||||
normalize([1.0, 2.0], feature_range=(0.0, 1.0, 2.0)) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_standardize_has_zero_mean_unit_std(rng):
|
|
||||||
from mcm_toolkit.data import standardize
|
|
||||||
|
|
||||||
x = rng.normal(5, 3, size=1000)
|
|
||||||
out = np.asarray(standardize(x))
|
|
||||||
assert abs(out.mean()) < 1e-9
|
|
||||||
assert abs(out.std() - 1.0) < 1e-9
|
|
||||||
|
|
||||||
|
|
||||||
def test_standardize_is_per_column(rng):
|
|
||||||
"""按列标准化:每列各自均值 0、标准差 1。"""
|
|
||||||
from mcm_toolkit.data import standardize
|
|
||||||
|
|
||||||
x = rng.normal(size=(200, 3)) * [1.0, 10.0, 100.0] + [0.0, 50.0, -3.0]
|
|
||||||
out = np.asarray(standardize(x))
|
|
||||||
assert np.allclose(out.mean(axis=0), 0.0, atol=1e-12)
|
|
||||||
assert np.allclose(out.std(axis=0), 1.0, atol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_standardize_honours_ddof():
|
|
||||||
"""ddof 要真的传下去:ddof=0 才得到单位方差;ddof=1 时结果的 std = sqrt((n-1)/n)。
|
|
||||||
|
|
||||||
除以更大的样本标准差,结果自然更小 —— 所以 ddof=1 时 out.std() **小于** 1。
|
|
||||||
"""
|
|
||||||
from mcm_toolkit.data import standardize
|
|
||||||
|
|
||||||
x = np.array([1.0, 2.0, 3.0, 4.0])
|
|
||||||
assert np.allclose(standardize(x, ddof=0).std(), 1.0)
|
|
||||||
assert np.allclose(standardize(x, ddof=1).std(), np.sqrt(3.0 / 4.0))
|
|
||||||
|
|
||||||
|
|
||||||
def test_standardize_constant_column_stays_finite():
|
|
||||||
from mcm_toolkit.data import standardize
|
|
||||||
|
|
||||||
out = np.asarray(standardize([[2.0, 1.0], [2.0, 3.0]]))
|
|
||||||
assert np.allclose(out[:, 0], 0.0)
|
|
||||||
assert bool(np.isfinite(out).all())
|
|
||||||
|
|
||||||
|
|
||||||
def test_standardize_rejects_bad_type():
|
|
||||||
from mcm_toolkit.data import standardize
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
standardize("abc")
|
|
||||||
|
|
||||||
|
|
||||||
def test_log_transform_shifts_to_keep_domain():
|
|
||||||
"""含 0 或负值时自动取 offset = 1 - min(x),保证 log 的定义域。"""
|
|
||||||
from mcm_toolkit.data import log_transform
|
|
||||||
|
|
||||||
x = np.array([-5.0, 0.0, 5.0])
|
|
||||||
out = np.asarray(log_transform(x))
|
|
||||||
assert bool(np.isfinite(out).all())
|
|
||||||
assert np.allclose(out, np.log(x + 6.0))
|
|
||||||
|
|
||||||
|
|
||||||
def test_log_transform_positive_data_needs_no_offset():
|
|
||||||
from mcm_toolkit.data import log_transform
|
|
||||||
|
|
||||||
assert np.allclose(log_transform([1.0, np.e]), [0.0, 1.0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_log_transform_rejects_bad_type():
|
|
||||||
from mcm_toolkit.data import log_transform
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
log_transform("abc")
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_hot_encode_auto_detects_text_columns_quietly():
|
|
||||||
"""pandas 3 起字符串列的 dtype 是 'str':要能选中,且不能走已弃用的 select_dtypes 路径。"""
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
from mcm_toolkit.data import one_hot_encode
|
|
||||||
|
|
||||||
df = pd.DataFrame({"city": ["A", "B", "C"], "v": [1.0, 2.0, 3.0]})
|
|
||||||
with warnings.catch_warnings(record=True) as caught:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
out = one_hot_encode(df)
|
|
||||||
|
|
||||||
messages = [str(w.message) for w in caught]
|
|
||||||
assert not messages, f"自动识别不该产生任何告警,实际:{messages}"
|
|
||||||
assert "city_A" in out.columns
|
|
||||||
assert "v" in out.columns
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_hot_encode_explicit_columns_and_drop_first():
|
|
||||||
from mcm_toolkit.data import one_hot_encode
|
|
||||||
|
|
||||||
df = pd.DataFrame({"city": ["A", "B", "C"], "v": [1.0, 2.0, 3.0]})
|
|
||||||
out = one_hot_encode(df, columns=["city"], drop_first=True)
|
|
||||||
assert "city_A" not in out.columns
|
|
||||||
assert "city_B" in out.columns
|
|
||||||
assert "city_C" in out.columns
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_hot_encode_rejects_missing_column_and_non_frame():
|
|
||||||
from mcm_toolkit.data import one_hot_encode
|
|
||||||
|
|
||||||
df = pd.DataFrame({"city": ["A"], "v": [1.0]})
|
|
||||||
with pytest.raises(ValueError, match="nope"):
|
|
||||||
one_hot_encode(df, columns=["nope"])
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
one_hot_encode([1, 2, 3])
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------- 以下为未实现算法(L0/L1 待写)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
|
||||||
def test_train_test_split_sizes_and_no_overlap(rng):
|
|
||||||
from mcm_toolkit.data import train_test_split
|
|
||||||
|
|
||||||
X = rng.normal(size=(100, 2))
|
|
||||||
Xtr, Xte = train_test_split(X, test_size=0.2, random_state=0)
|
|
||||||
assert len(Xtr) == 80 and len(Xte) == 20
|
|
||||||
assert len({tuple(r) for r in Xtr} & {tuple(r) for r in Xte}) == 0
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""decision 包测试。"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L1")
|
|
||||||
|
|
||||||
|
|
||||||
def test_topsis_ranks_dominated_alternative_last():
|
|
||||||
"""一个在所有指标上都最差的方案,TOPSIS 必须排最后。"""
|
|
||||||
from mcm_toolkit.decision import topsis
|
|
||||||
|
|
||||||
X = np.array(
|
|
||||||
[
|
|
||||||
[9.0, 8.0, 7.0], # 全面最优
|
|
||||||
[5.0, 5.0, 5.0], # 中等
|
|
||||||
[1.0, 1.0, 1.0], # 全面最差
|
|
||||||
]
|
|
||||||
)
|
|
||||||
r = topsis(X, benefit_columns=[0, 1, 2])
|
|
||||||
ranks = dict(zip(range(3), r.data["rank"], strict=False))
|
|
||||||
assert ranks[0] == 1
|
|
||||||
assert ranks[2] == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_topsis_direction_flips_result():
|
|
||||||
"""把某列从效益型改成成本型,排名必须变——方向不能静默搞错。"""
|
|
||||||
from mcm_toolkit.decision import topsis
|
|
||||||
|
|
||||||
X = np.array([[9.0, 1.0], [1.0, 9.0]])
|
|
||||||
a = topsis(X, benefit_columns=[0, 1])
|
|
||||||
b = topsis(X, benefit_columns=[0])
|
|
||||||
assert list(a.data["rank"]) != list(b.data["rank"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_entropy_weights_sum_to_one_and_prefers_dispersed_column(rng):
|
|
||||||
from mcm_toolkit.decision import entropy_weights
|
|
||||||
|
|
||||||
X = np.column_stack([rng.normal(size=50), np.ones(50)]) # 第二列无差异
|
|
||||||
r = entropy_weights(X)
|
|
||||||
assert np.isclose(r.params["weights"].sum(), 1.0)
|
|
||||||
assert r.params["weights"][0] > r.params["weights"][1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_entropy_weights_handles_zero_and_negative():
|
|
||||||
"""熵权法的三个坑之一:零值不能产生 NaN。"""
|
|
||||||
from mcm_toolkit.decision import entropy_weights
|
|
||||||
|
|
||||||
r = entropy_weights(np.array([[0.0, 1.0], [1.0, 2.0], [2.0, 0.0]]))
|
|
||||||
assert np.isfinite(r.params["weights"]).all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_ahp_consistency_for_consistent_matrix():
|
|
||||||
"""完全一致的判断矩阵 CR 应接近 0。"""
|
|
||||||
from mcm_toolkit.decision import ahp_weights
|
|
||||||
|
|
||||||
A = np.array([[1, 2, 4], [0.5, 1, 2], [0.25, 0.5, 1]], dtype=float)
|
|
||||||
r = ahp_weights(A)
|
|
||||||
assert r.metrics["CR"] < 0.01
|
|
||||||
|
|
||||||
|
|
||||||
def test_ahp_weights_match_geometric_mean_for_consistent_matrix():
|
|
||||||
from mcm_toolkit.decision import ahp_weights
|
|
||||||
|
|
||||||
A = np.array([[1, 2, 4], [0.5, 1, 2], [0.25, 0.5, 1]], dtype=float)
|
|
||||||
r = ahp_weights(A)
|
|
||||||
geo = (A.prod(axis=1)) ** (1 / 3)
|
|
||||||
assert np.allclose(r.params["weights"], geo / geo.sum(), atol=1e-3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_gm11_fits_exponential_sequence():
|
|
||||||
"""GM(1,1) 对纯指数序列应拟合得极好。"""
|
|
||||||
from mcm_toolkit.decision import gm11_fit_predict
|
|
||||||
|
|
||||||
series = [2.0 * 1.1**k for k in range(6)]
|
|
||||||
r = gm11_fit_predict(series, steps=2)
|
|
||||||
assert r.metrics["c_ratio"] < 0.35 # 后验差一级
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
"""models 包测试。"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L1"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
|
||||||
def test_fit_linear_recovers_known_coefficients(linear_data):
|
|
||||||
"""真值 y = 2x + 1,拟合系数应在 1% 内。"""
|
|
||||||
from mcm_toolkit.models import fit_linear
|
|
||||||
|
|
||||||
x, y = linear_data
|
|
||||||
r = fit_linear(x.reshape(-1, 1), y)
|
|
||||||
assert np.isclose(r.params["coef"][0], 2.0, rtol=0.01)
|
|
||||||
assert np.isclose(r.params["intercept"], 1.0, atol=0.05)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
|
||||||
def test_fit_polynomial_matches_numpy(linear_data):
|
|
||||||
from mcm_toolkit.models import fit_polynomial
|
|
||||||
|
|
||||||
x, y = linear_data
|
|
||||||
r = fit_polynomial(x.reshape(-1, 1), y, degree=2)
|
|
||||||
assert np.allclose(r.params["coef"], np.polyfit(x, y, 2), rtol=1e-6)
|
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_known_values():
|
|
||||||
from mcm_toolkit.models import mae, r2_score, rmse
|
|
||||||
|
|
||||||
y_true = np.array([1.0, 2.0, 3.0])
|
|
||||||
y_pred = np.array([1.0, 2.0, 5.0])
|
|
||||||
assert np.isclose(rmse(y_true, y_pred), np.sqrt(4 / 3))
|
|
||||||
assert np.isclose(mae(y_true, y_pred), 2 / 3)
|
|
||||||
assert r2_score(y_true, y_pred) < 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_r2_is_one_for_perfect_fit():
|
|
||||||
from mcm_toolkit.models import r2_score
|
|
||||||
|
|
||||||
y = np.array([1.0, 2.0, 3.0, 4.0])
|
|
||||||
assert np.isclose(r2_score(y, y), 1.0)
|
|
||||||
|
|
||||||
|
|
||||||
class _MeanModel:
|
|
||||||
"""最笨的模型:永远预测训练集的均值(`compare_models` 的对照组)。"""
|
|
||||||
|
|
||||||
def fit(self, X, y):
|
|
||||||
self.mu_ = float(np.mean(y))
|
|
||||||
return self
|
|
||||||
|
|
||||||
def predict(self, X):
|
|
||||||
return np.full(X.shape[0], self.mu_)
|
|
||||||
|
|
||||||
|
|
||||||
class _LinearModel:
|
|
||||||
"""一列特征的普通最小二乘(`compare_models` 的"好模型")。"""
|
|
||||||
|
|
||||||
def fit(self, X, y):
|
|
||||||
self.coef_ = np.polyfit(X[:, 0], y, 1)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def predict(self, X):
|
|
||||||
return np.polyval(self.coef_, X[:, 0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_compare_models_returns_table_sorted_by_first_metric(linear_data):
|
|
||||||
from mcm_toolkit.models import compare_models
|
|
||||||
|
|
||||||
x, y = linear_data
|
|
||||||
tbl = compare_models(
|
|
||||||
{"mean": _MeanModel(), "linear": _LinearModel()}, x.reshape(-1, 1), y, cv=0
|
|
||||||
)
|
|
||||||
|
|
||||||
assert list(tbl.columns) == ["rmse", "mae", "r2"]
|
|
||||||
assert list(tbl.index) == ["linear", "mean"] # 首列 rmse 升序 → 好的模型在前
|
|
||||||
assert tbl.loc["linear", "rmse"] < tbl.loc["mean", "rmse"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_reject_mismatched_shapes():
|
|
||||||
from mcm_toolkit.models import rmse
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="形状必须一致"):
|
|
||||||
rmse([1.0, 2.0], [1.0, 2.0, 3.0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_reject_empty_input():
|
|
||||||
from mcm_toolkit.models import rmse
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="不能为空"):
|
|
||||||
rmse(np.array([]), np.array([]))
|
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_reject_nan_instead_of_dropping_it():
|
|
||||||
"""含 NaN 一律报错,**绝不静默删点**:删了样本量就变了,指标不可比。"""
|
|
||||||
from mcm_toolkit.models import rmse
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="NaN"):
|
|
||||||
rmse([1.0, np.nan, 3.0], [1.0, 2.0, 3.0])
|
|
||||||
with pytest.raises(ValueError, match="NaN"):
|
|
||||||
rmse([1.0, 2.0, 3.0], [1.0, np.nan, 3.0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_reject_non_whitelisted_type():
|
|
||||||
"""`str` 也是可迭代对象,必须被入口白名单挡住,不能被当成三个字符算下去。"""
|
|
||||||
from mcm_toolkit.models import rmse
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
rmse("abc", "abc") # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", ["rmse", "mae", "mape", "smape", "r2_score"])
|
|
||||||
def test_metrics_agree_across_input_types(name):
|
|
||||||
"""`DataFrame` / `Series` / `ndarray` / `list` 四种入参必须给出同一个数。"""
|
|
||||||
import mcm_toolkit.models as models
|
|
||||||
|
|
||||||
yt = [1.0, 2.0, 3.0, 4.0]
|
|
||||||
yp = [1.1, 1.9, 3.2, 3.8]
|
|
||||||
fn = getattr(models, name)
|
|
||||||
base = fn(yt, yp)
|
|
||||||
|
|
||||||
assert np.isclose(fn(np.array(yt), np.array(yp)), base)
|
|
||||||
assert np.isclose(fn(pd.Series(yt), pd.Series(yp)), base)
|
|
||||||
assert np.isclose(fn(pd.DataFrame({"y": yt}), pd.DataFrame({"y": yp})), base)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mape_rejects_non_positive_y_true():
|
|
||||||
"""`y_true` 非正时 MAPE 无定义 —— 拦住并点名改用 smape,而不是返回负数或 inf。"""
|
|
||||||
from mcm_toolkit.models import mape
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="非正数"):
|
|
||||||
mape([0.0, 2.0], [1.0, 2.0])
|
|
||||||
with pytest.raises(ValueError, match="非正数"):
|
|
||||||
mape([-1.0, 2.0], [1.0, 2.0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_reject_infinite_input():
|
|
||||||
"""`Inf` 与 `NaN` 一样直接拦:算出来是 `inf` 的指标没有任何意义。"""
|
|
||||||
from mcm_toolkit.models import rmse
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Inf"):
|
|
||||||
rmse([1.0, np.inf], [1.0, 2.0])
|
|
||||||
with pytest.raises(ValueError, match="Inf"):
|
|
||||||
rmse([1.0, 2.0], [1.0, -np.inf])
|
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_require_at_least_two_samples():
|
|
||||||
"""1 个点的误差指标没有统计意义;标量也会被这条拦住(不是被当成 1 个样本)。"""
|
|
||||||
from mcm_toolkit.models import rmse
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="至少需要包含 2 个样本"):
|
|
||||||
rmse([1.0], [1.0])
|
|
||||||
with pytest.raises(ValueError, match="至少需要包含 2 个样本"):
|
|
||||||
rmse(1.0, 1.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_smape_is_silent_when_denominator_is_zero():
|
|
||||||
"""分子分母同时为 0 的点记 0,且**不能漏出 RuntimeWarning**。
|
|
||||||
|
|
||||||
本仓库 `pyproject.toml` 里开了 `filterwarnings = ["error::RuntimeWarning"]`,
|
|
||||||
所以这条测试同时守着"零除"这个坑:`np.where` 的两条分支都会被求值,
|
|
||||||
直接写除式就会把告警漏出来,测试会红。
|
|
||||||
"""
|
|
||||||
from mcm_toolkit.models import smape
|
|
||||||
|
|
||||||
assert smape([0.0, 0.0], [0.0, 0.0]) == 0.0
|
|
||||||
assert smape([0.0, 2.0], [0.0, 2.0]) == 0.0
|
|
||||||
assert np.isclose(smape([1.0, 1.0], [0.0, 1.0]), 1.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_adjusted_r2_rejects_invalid_n_features():
|
|
||||||
from mcm_toolkit.models import adjusted_r2
|
|
||||||
|
|
||||||
yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
|
||||||
yp = np.array([1.5, 2.5, 2.5, 4.5, 5.5])
|
|
||||||
|
|
||||||
for bad in (True, 1.0, np.float64(1.0)):
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
adjusted_r2(yt, yp, bad) # type: ignore[arg-type]
|
|
||||||
with pytest.raises(ValueError, match="正整数"):
|
|
||||||
adjusted_r2(yt, yp, 0)
|
|
||||||
with pytest.raises(ValueError, match="样本量太小"):
|
|
||||||
adjusted_r2(yt, yp, 4) # n = 5,分母 n - k - 1 = 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_adjusted_r2_accepts_numpy_integer():
|
|
||||||
from mcm_toolkit.models import adjusted_r2
|
|
||||||
|
|
||||||
yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
|
||||||
yp = np.array([1.5, 2.5, 2.5, 4.5, 5.5])
|
|
||||||
|
|
||||||
assert adjusted_r2(yt, yp, np.int64(1)) == adjusted_r2(yt, yp, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_adjusted_r2_penalises_extra_features():
|
|
||||||
"""同一份数据、同一个 R²,自变量越多,调整 R² 越小(没用的变量只吃惩罚)。"""
|
|
||||||
from mcm_toolkit.models import adjusted_r2
|
|
||||||
|
|
||||||
yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
|
|
||||||
yp = np.array([1.2, 2.1, 3.3, 3.8, 5.1, 6.2])
|
|
||||||
|
|
||||||
assert adjusted_r2(yt, yp, 3) < adjusted_r2(yt, yp, 2) < adjusted_r2(yt, yp, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_r2_matches_analytic_formula():
|
|
||||||
"""`R² = 1 - SS_res / SS_tot`,且 `SS_tot` 的基准必须是 **y_true 的均值**。
|
|
||||||
|
|
||||||
这里刻意用**非完美拟合**的数据:完美拟合(R² = 1)和常量 y(R² = 0)都能把
|
|
||||||
"基准量抄错对象"这类 bug 掩盖掉(个人易错点 31)。
|
|
||||||
"""
|
|
||||||
from mcm_toolkit.models import r2_score
|
|
||||||
|
|
||||||
yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
|
||||||
yp = np.array([1.5, 2.5, 2.5, 4.5, 5.5])
|
|
||||||
ss_res = np.sum((yt - yp) ** 2)
|
|
||||||
ss_tot = np.sum((yt - np.mean(yt)) ** 2)
|
|
||||||
|
|
||||||
assert np.isclose(r2_score(yt, yp), 1 - ss_res / ss_tot)
|
|
||||||
assert np.isclose(r2_score(yt, yp), 0.875)
|
|
||||||
|
|
||||||
|
|
||||||
def test_r2_matches_sklearn():
|
|
||||||
"""拿成熟库当**独立的参照物**:自实现指标最容易"自己算自己"。"""
|
|
||||||
sk = pytest.importorskip("sklearn.metrics")
|
|
||||||
|
|
||||||
from mcm_toolkit.models import r2_score
|
|
||||||
|
|
||||||
rng = np.random.default_rng(0)
|
|
||||||
yt = rng.normal(size=50)
|
|
||||||
yp = yt + rng.normal(scale=0.5, size=50)
|
|
||||||
|
|
||||||
assert np.isclose(r2_score(yt, yp), sk.r2_score(yt, yp))
|
|
||||||
|
|
||||||
|
|
||||||
def test_r2_is_negative_when_worse_than_predicting_the_mean():
|
|
||||||
from mcm_toolkit.models import r2_score
|
|
||||||
|
|
||||||
assert np.isclose(r2_score([1.0, 2.0, 3.0], [3.0, 2.0, 1.0]), -3.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_adjusted_r2_matches_analytic_formula():
|
|
||||||
from mcm_toolkit.models import adjusted_r2, r2_score
|
|
||||||
|
|
||||||
yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
|
|
||||||
yp = np.array([1.2, 2.1, 3.3, 3.8, 5.1, 6.2])
|
|
||||||
n, k = len(yt), 2
|
|
||||||
r2 = r2_score(yt, yp)
|
|
||||||
|
|
||||||
assert np.isclose(adjusted_r2(yt, yp, k), 1 - (1 - r2) * (n - 1) / (n - k - 1))
|
|
||||||
|
|
||||||
|
|
||||||
def test_compare_models_treats_r2_names_as_aliases(linear_data):
|
|
||||||
"""`"r2"` 与 `"r2_score"` 是同一个指标的别名:传哪个、列名就叫哪个,数值相同。"""
|
|
||||||
from mcm_toolkit.models import compare_models
|
|
||||||
|
|
||||||
x, y = linear_data
|
|
||||||
X = x.reshape(-1, 1)
|
|
||||||
short = compare_models({"m": _LinearModel()}, X, y, metrics=("rmse", "r2"), cv=0)
|
|
||||||
full = compare_models({"m": _LinearModel()}, X, y, metrics=("rmse", "r2_score"), cv=0)
|
|
||||||
|
|
||||||
assert list(short.columns) == ["rmse", "r2"]
|
|
||||||
assert list(full.columns) == ["rmse", "r2_score"]
|
|
||||||
assert np.isclose(short.loc["m", "r2"], full.loc["m", "r2_score"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_compare_models_sorts_descending_when_first_metric_is_r2(linear_data):
|
|
||||||
"""误差类是**越小越好**、R² 是**越大越好**,排序方向必须跟着首列指标走。"""
|
|
||||||
from mcm_toolkit.models import compare_models
|
|
||||||
|
|
||||||
x, y = linear_data
|
|
||||||
tbl = compare_models(
|
|
||||||
{"mean": _MeanModel(), "linear": _LinearModel()},
|
|
||||||
x.reshape(-1, 1),
|
|
||||||
y,
|
|
||||||
metrics=("r2",),
|
|
||||||
cv=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert list(tbl.index) == ["linear", "mean"] # r2 降序 → 好的模型仍在前
|
|
||||||
|
|
||||||
|
|
||||||
def test_compare_models_rejects_bad_metric_names(linear_data):
|
|
||||||
from mcm_toolkit.models import compare_models
|
|
||||||
|
|
||||||
x, y = linear_data
|
|
||||||
X = x.reshape(-1, 1)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="不认识的指标名"):
|
|
||||||
compare_models({"m": _MeanModel()}, X, y, metrics=("mse",), cv=0)
|
|
||||||
with pytest.raises(ValueError, match="别名"):
|
|
||||||
compare_models({"m": _MeanModel()}, X, y, metrics=("r2", "r2_score"), cv=0)
|
|
||||||
with pytest.raises(TypeError, match="不要传单个字符串"):
|
|
||||||
compare_models({"m": _MeanModel()}, X, y, metrics="rmse", cv=0) # type: ignore[arg-type]
|
|
||||||
with pytest.raises(TypeError, match="tuple / list"):
|
|
||||||
compare_models({"m": _MeanModel()}, X, y, metrics=None, cv=0) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_compare_models_rejects_bad_models_and_cv(linear_data):
|
|
||||||
from mcm_toolkit.models import compare_models
|
|
||||||
|
|
||||||
x, y = linear_data
|
|
||||||
X = x.reshape(-1, 1)
|
|
||||||
|
|
||||||
with pytest.raises(TypeError, match="字典"):
|
|
||||||
compare_models([_MeanModel()], X, y) # type: ignore[arg-type]
|
|
||||||
with pytest.raises(ValueError, match="不能为空"):
|
|
||||||
compare_models({}, X, y)
|
|
||||||
with pytest.raises(TypeError, match="Model 协议"):
|
|
||||||
compare_models({"bad": object()}, X, y, cv=0)
|
|
||||||
with pytest.raises(ValueError, match="cv=1"):
|
|
||||||
compare_models({"m": _MeanModel()}, X, y, cv=1)
|
|
||||||
with pytest.raises(ValueError, match="太大"):
|
|
||||||
compare_models({"m": _MeanModel()}, X, y, cv=1000)
|
|
||||||
with pytest.raises(ValueError, match="样本数必须一致"):
|
|
||||||
compare_models({"m": _MeanModel()}, X, y[:-1], cv=0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_compare_models_needs_two_samples_per_fold():
|
|
||||||
"""每折至少 2 个样本(指标自己的入口要求),所以 cv 最多 n // 2。"""
|
|
||||||
from mcm_toolkit.models import compare_models
|
|
||||||
|
|
||||||
X = np.arange(4, dtype=float).reshape(-1, 1)
|
|
||||||
y = np.arange(4, dtype=float)
|
|
||||||
|
|
||||||
assert compare_models({"m": _MeanModel()}, X, y, cv=2, random_state=0).shape == (1, 3)
|
|
||||||
with pytest.raises(ValueError, match="太大"):
|
|
||||||
compare_models({"m": _MeanModel()}, X, y, cv=3, random_state=0)
|
|
||||||
with pytest.raises(ValueError, match="样本太少"):
|
|
||||||
compare_models({"m": _MeanModel()}, X[:1], y[:1], cv=0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_compare_models_is_reproducible_with_random_state(linear_data):
|
|
||||||
"""分折用了随机数,所以必须服从 `random_state`(架构 §4.2)。"""
|
|
||||||
from mcm_toolkit.models import compare_models
|
|
||||||
|
|
||||||
x, y = linear_data
|
|
||||||
X = x.reshape(-1, 1)
|
|
||||||
models = {"mean": _MeanModel(), "linear": _LinearModel()}
|
|
||||||
|
|
||||||
first = compare_models(models, X, y, cv=5, random_state=42)
|
|
||||||
second = compare_models(models, X, y, cv=5, random_state=42)
|
|
||||||
|
|
||||||
assert first.equals(second)
|
|
||||||
@@ -1,721 +0,0 @@
|
|||||||
"""solve 包测试。"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L0/L1"
|
|
||||||
|
|
||||||
|
|
||||||
def _normal_batch(rng, size, **kwargs):
|
|
||||||
"""`monte_carlo` 的 sampler 契约:收下实现传来的 `size`,一次抽够。"""
|
|
||||||
return rng.normal(0, 1, size)
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_integral_converges_to_analytic_value():
|
|
||||||
"""∫₀¹ x² dx = 1/3。n=2e5 时误差应 < 0.01。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo_integral
|
|
||||||
|
|
||||||
r = monte_carlo_integral(lambda X: X[:, 0] ** 2, bounds=[(0, 1)], n=200_000, random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert abs(r.metrics["mean"] - 1 / 3) < 0.01 # 体积必须是正的
|
|
||||||
assert len(r.data) == 200_000 # data 是 n 个样本各自的贡献
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_integral_odd_dimension_sign():
|
|
||||||
"""∫∫∫ x dV over [0,1]³ = 1/2 —— 奇数维最容易暴露"体积符号写反"。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo_integral
|
|
||||||
|
|
||||||
r = monte_carlo_integral(lambda X: X[:, 0], bounds=[(0, 1)] * 3, n=200_000, random_state=0)
|
|
||||||
assert abs(r.metrics["mean"] - 0.5) < 0.01
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_integral_signed_bounds():
|
|
||||||
"""∫₀² x dx = 2 —— 边界带宽度时,体积算错和符号写反都会现形。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo_integral
|
|
||||||
|
|
||||||
r = monte_carlo_integral(lambda X: X[:, 0], bounds=[(0, 2)], n=200_000, random_state=0)
|
|
||||||
assert abs(r.metrics["mean"] - 2.0) < 0.02
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_integral_2d_and_params():
|
|
||||||
from mcm_toolkit.solve import monte_carlo_integral
|
|
||||||
|
|
||||||
bounds = [(0, 1), (0, 1)]
|
|
||||||
r = monte_carlo_integral(lambda X: X[:, 0] * X[:, 1], bounds=bounds, n=200_000, random_state=0)
|
|
||||||
assert abs(r.metrics["mean"] - 0.25) < 0.01
|
|
||||||
assert r.name == "monte_carlo_integral"
|
|
||||||
assert r.params["dimension"] == 2
|
|
||||||
assert r.params["bounds"] == bounds
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_integral_rejects_bad_bounds():
|
|
||||||
"""四类非法 bounds 都要在入口抛 ValueError:区间反了 / 漏括号 / 空 / 参差不齐。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo_integral
|
|
||||||
|
|
||||||
for bad in ([(1, 0)], [0, 1], [], [(0, 1), (0, 2, 3)]):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
monte_carlo_integral(lambda X: X[:, 0], bounds=bad, n=100, random_state=0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_integral_failure_message_stays_failure():
|
|
||||||
"""失败时 message 不能被包装层改写成"成功"(易错点 22)。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo_integral
|
|
||||||
|
|
||||||
r = monte_carlo_integral(lambda X: 1.0, bounds=[(0, 1)], n=100, random_state=0)
|
|
||||||
assert r.success is False
|
|
||||||
assert "成功" not in r.message
|
|
||||||
assert "失败" in r.message
|
|
||||||
assert r.params["dimension"] == 1 # 失败也要留下追溯信息
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_integral_accepts_numpy_integer_n():
|
|
||||||
"""§4.7:`np.int64` 不是 `int` 的子类,注解与运行期都要收(易错点 1)。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo_integral
|
|
||||||
|
|
||||||
r = monte_carlo_integral(lambda X: X[:, 0], bounds=[(0, 1)], n=np.int64(1000), random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert len(r.data) == 1000
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_probability_of_standard_normal():
|
|
||||||
"""P(Z > 0) = 0.5,n=5000 时误差应 < 0.05。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo_probability
|
|
||||||
|
|
||||||
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=5000, random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert abs(r.metrics["probability"] - 0.5) < 0.05
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_is_reproducible():
|
|
||||||
from mcm_toolkit.solve import monte_carlo_probability
|
|
||||||
|
|
||||||
a = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=1000, random_state=9)
|
|
||||||
b = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=1000, random_state=9)
|
|
||||||
assert a.metrics["probability"] == b.metrics["probability"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_returns_n_results_and_documented_metrics():
|
|
||||||
from mcm_toolkit.solve import monte_carlo
|
|
||||||
|
|
||||||
r = monte_carlo(_normal_batch, lambda x: x**2, n=2000, random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert r.data.shape == (2000,)
|
|
||||||
assert set(r.metrics) == {"mean", "std", "median", "p05", "p95", "ci_low", "ci_high"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_calls_simulator_only_once():
|
|
||||||
"""`simulator` 只被调用一次 —— 这是"必须向量化"的可执行版本。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo
|
|
||||||
|
|
||||||
counter = {"calls": 0}
|
|
||||||
|
|
||||||
def simulator(params):
|
|
||||||
counter["calls"] += 1
|
|
||||||
return params
|
|
||||||
|
|
||||||
r = monte_carlo(_normal_batch, simulator, n=1000, random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert counter["calls"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_data_is_reproducible_with_same_seed():
|
|
||||||
from mcm_toolkit.solve import monte_carlo
|
|
||||||
|
|
||||||
a = monte_carlo(_normal_batch, lambda x: x, n=2000, random_state=11)
|
|
||||||
b = monte_carlo(_normal_batch, lambda x: x, n=2000, random_state=11)
|
|
||||||
assert np.array_equal(a.data, b.data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_follows_set_seed_when_random_state_omitted():
|
|
||||||
from mcm_toolkit.core import set_seed
|
|
||||||
from mcm_toolkit.solve import monte_carlo
|
|
||||||
|
|
||||||
set_seed(5)
|
|
||||||
a = monte_carlo(_normal_batch, lambda x: x, n=500)
|
|
||||||
set_seed(5)
|
|
||||||
b = monte_carlo(_normal_batch, lambda x: x, n=500)
|
|
||||||
assert np.array_equal(a.data, b.data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_estimates_known_expectation():
|
|
||||||
"""E[X²] = 1(X ~ N(0, 1));n = 2e5 时误差应 < 0.02。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo
|
|
||||||
|
|
||||||
r = monte_carlo(_normal_batch, lambda x: x**2, n=200_000, random_state=0)
|
|
||||||
assert abs(r.metrics["mean"] - 1.0) < 0.02
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_docstring_example_runs():
|
|
||||||
"""docstring 的 Examples 必须真的能跑通(此前那一版是坏的)。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo
|
|
||||||
|
|
||||||
draw = lambda rng, size, **k: rng.normal(0, 1, size) # noqa: E731
|
|
||||||
r = monte_carlo(draw, lambda x: x**2, n=10000, random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert abs(r.metrics["mean"] - 1.0) < 0.1
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_accepts_numpy_integer_n():
|
|
||||||
"""§4.7:`np.int64` 不是 `int` 的子类,不能漏。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo
|
|
||||||
|
|
||||||
r = monte_carlo(_normal_batch, lambda x: x, n=np.int64(100), random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert r.data.shape == (100,)
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_counts_boolean_outcomes():
|
|
||||||
"""布尔结果按 0/1 统计 —— 事件概率是 MC 最常见的用法。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo
|
|
||||||
|
|
||||||
r = monte_carlo(
|
|
||||||
lambda rng, size, **k: rng.normal(0, 1, size) > 0,
|
|
||||||
lambda x: x,
|
|
||||||
n=5000,
|
|
||||||
random_state=0,
|
|
||||||
)
|
|
||||||
assert r.success
|
|
||||||
assert r.data.dtype == float
|
|
||||||
assert abs(r.metrics["mean"] - 0.5) < 0.05
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_rejects_nan_results():
|
|
||||||
"""结果里出现 NaN 就判失败,不静默给出一列 nan 指标。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo
|
|
||||||
|
|
||||||
r = monte_carlo(
|
|
||||||
lambda rng, size, **k: np.r_[np.zeros(size - 1), np.nan],
|
|
||||||
lambda x: x,
|
|
||||||
n=10,
|
|
||||||
random_state=0,
|
|
||||||
)
|
|
||||||
assert not r.success
|
|
||||||
assert "NaN" in r.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_rejects_bad_arguments():
|
|
||||||
from mcm_toolkit.solve import monte_carlo
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
monte_carlo(123, lambda x: x) # type: ignore[arg-type] # 就是要传错
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
monte_carlo(_normal_batch, 123) # type: ignore[arg-type] # 就是要传错
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
monte_carlo(_normal_batch, lambda x: x, n=True)
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
monte_carlo(_normal_batch, lambda x: x, n=10.0) # type: ignore[arg-type] # 就是要传错
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
monte_carlo(_normal_batch, lambda x: x, n=0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_probability_returns_documented_metrics():
|
|
||||||
from mcm_toolkit.solve import monte_carlo_probability
|
|
||||||
|
|
||||||
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=20000, random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert set(r.metrics) == {"probability", "ci_low", "ci_high", "std_error"}
|
|
||||||
assert r.data.shape == (20000,)
|
|
||||||
assert 0.0 <= r.metrics["ci_low"] <= r.metrics["probability"]
|
|
||||||
assert r.metrics["probability"] <= r.metrics["ci_high"] <= 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_probability_std_error_matches_binomial_formula():
|
|
||||||
"""`std_error` 就是比例的标准误 √(p(1-p)/n)。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo_probability
|
|
||||||
|
|
||||||
n = 20000
|
|
||||||
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=n, random_state=0)
|
|
||||||
p = r.metrics["probability"]
|
|
||||||
assert np.isclose(r.metrics["std_error"], np.sqrt(p * (1 - p) / n))
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_probability_ci_narrows_with_n():
|
|
||||||
"""区间宽度随 n 增大而收窄(O(1/√n))。"""
|
|
||||||
from mcm_toolkit.solve import monte_carlo_probability
|
|
||||||
|
|
||||||
def width(n):
|
|
||||||
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=n, random_state=0)
|
|
||||||
return r.metrics["ci_high"] - r.metrics["ci_low"]
|
|
||||||
|
|
||||||
assert width(100_000) < width(1_000)
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_probability_higher_confidence_is_wider():
|
|
||||||
from mcm_toolkit.solve import monte_carlo_probability
|
|
||||||
|
|
||||||
def width(confidence):
|
|
||||||
r = monte_carlo_probability(
|
|
||||||
lambda z: z > 0, _normal_batch, n=5000, random_state=0, confidence=confidence
|
|
||||||
)
|
|
||||||
return r.metrics["ci_high"] - r.metrics["ci_low"]
|
|
||||||
|
|
||||||
assert width(0.99) > width(0.90)
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_probability_ci_matches_statsmodels_wilson():
|
|
||||||
"""区间必须对得上独立实现(statsmodels 的 wilson)—— `^` 写成异或那次就靠它拦住。"""
|
|
||||||
from statsmodels.stats.proportion import proportion_confint
|
|
||||||
|
|
||||||
from mcm_toolkit.solve import monte_carlo_probability
|
|
||||||
|
|
||||||
n = 5000
|
|
||||||
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=n, random_state=0)
|
|
||||||
count = int(r.data.sum())
|
|
||||||
lo, hi = proportion_confint(count, n, alpha=0.05, method="wilson")
|
|
||||||
assert np.isclose(r.metrics["ci_low"], lo)
|
|
||||||
assert np.isclose(r.metrics["ci_high"], hi)
|
|
||||||
|
|
||||||
|
|
||||||
def test_monte_carlo_probability_rejects_bad_confidence():
|
|
||||||
from mcm_toolkit.solve import monte_carlo_probability
|
|
||||||
|
|
||||||
for bad in (0.0, 1.0, -0.5, 1.5):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
monte_carlo_probability(lambda z: z > 0, _normal_batch, n=100, confidence=bad)
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_walk_shape_and_start():
|
|
||||||
"""`data` 形状 (n_paths, n_steps + 1),第 0 列是起点。"""
|
|
||||||
from mcm_toolkit.solve import random_walk
|
|
||||||
|
|
||||||
r = random_walk(n_steps=100, n_paths=5, start=2.5, random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert r.data.shape == (5, 101)
|
|
||||||
assert np.all(r.data[:, 0] == 2.5)
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_walk_default_steps_are_plus_minus_one():
|
|
||||||
"""默认是对称随机游走:每一步都恰好是 ±1。"""
|
|
||||||
from mcm_toolkit.solve import random_walk
|
|
||||||
|
|
||||||
r = random_walk(n_steps=200, n_paths=3, random_state=0)
|
|
||||||
assert set(np.unique(np.diff(r.data, axis=1))) == {-1.0, 1.0}
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_walk_is_reproducible():
|
|
||||||
from mcm_toolkit.solve import random_walk
|
|
||||||
|
|
||||||
a = random_walk(n_steps=50, n_paths=4, random_state=7)
|
|
||||||
b = random_walk(n_steps=50, n_paths=4, random_state=7)
|
|
||||||
assert np.array_equal(a.data, b.data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_walk_accepts_custom_step_sampler():
|
|
||||||
"""换成高斯增量就是带漂移的高斯游走 —— 建模差异全在 step_sampler 里。"""
|
|
||||||
from mcm_toolkit.solve import random_walk
|
|
||||||
|
|
||||||
r = random_walk(
|
|
||||||
n_steps=100,
|
|
||||||
n_paths=3,
|
|
||||||
step_sampler=lambda rng, size: rng.normal(0.5, 1.0, size),
|
|
||||||
random_state=0,
|
|
||||||
)
|
|
||||||
assert r.success
|
|
||||||
assert r.data.shape == (3, 101)
|
|
||||||
assert abs(r.data[:, -1].mean() - 50.0) < 20.0 # 100 步 × 漂移 0.5
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_walk_rejects_bad_arguments():
|
|
||||||
"""§4.7 的分工:类型不对抛 TypeError,取值不对(<= 0)抛 ValueError。"""
|
|
||||||
from mcm_toolkit.solve import random_walk
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
random_walk(n_steps=2.5) # type: ignore[arg-type] # 就是要传错
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
random_walk(n_steps=True)
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
random_walk(n_steps=10, n_paths=1.5) # type: ignore[arg-type] # 就是要传错
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
random_walk(n_steps=0)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
random_walk(n_steps=10, n_paths=0)
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
random_walk(n_steps=10, start="a") # type: ignore[arg-type] # 就是要传错
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_walk_rejects_2d_step_sampler():
|
|
||||||
"""`step_sampler` 必须返回**一维** —— 二维返回(含方阵)一律判失败(易错点 25 的根治)。"""
|
|
||||||
from mcm_toolkit.solve import random_walk
|
|
||||||
|
|
||||||
def transposed(rng, size):
|
|
||||||
return rng.normal(0, 1, (4, 3)) # 无论要多少,一律给二维
|
|
||||||
|
|
||||||
r = random_walk(n_steps=4, n_paths=3, step_sampler=transposed, random_state=0)
|
|
||||||
assert r.success is False # 失败也要返回 Result,不能抛给调用方
|
|
||||||
assert "长度" in r.message
|
|
||||||
assert r.params["n_paths"] == 3 # 失败也要留下追溯信息
|
|
||||||
assert r.data is None
|
|
||||||
|
|
||||||
# 旧方案在 n_paths == n_steps 时对"转置"完全无感;现在同样拦住
|
|
||||||
assert random_walk(n_steps=3, n_paths=3, step_sampler=transposed, random_state=0).success is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_walk_step_sampler_gets_flat_size_and_is_row_major():
|
|
||||||
"""契约:`size` 是整数 `n_paths * n_steps`,一维增量按**行主序**切成 n_paths 行。"""
|
|
||||||
from mcm_toolkit.solve import random_walk
|
|
||||||
|
|
||||||
seen = {}
|
|
||||||
|
|
||||||
def sampler(rng, size):
|
|
||||||
seen["size"] = size
|
|
||||||
return np.arange(1, size + 1, dtype=float) # 1, 2, 3, 4, 5, 6
|
|
||||||
|
|
||||||
r = random_walk(n_steps=3, n_paths=2, step_sampler=sampler, random_state=0)
|
|
||||||
assert seen["size"] == 6 # 整数,不是 (n_paths, n_steps)
|
|
||||||
assert r.data.tolist() == [[0, 1, 3, 6], [0, 4, 9, 15]] # cumsum(1,2,3) / cumsum(4,5,6)
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_walk_rejects_nan_steps():
|
|
||||||
"""增量含 NaN 判失败 —— 与 monte_carlo 的 NaN 口径(决策 48)一致。"""
|
|
||||||
from mcm_toolkit.solve import random_walk
|
|
||||||
|
|
||||||
r = random_walk(
|
|
||||||
n_steps=4,
|
|
||||||
n_paths=2,
|
|
||||||
step_sampler=lambda rng, size: np.full(size, np.nan),
|
|
||||||
random_state=0,
|
|
||||||
)
|
|
||||||
assert r.success is False
|
|
||||||
assert "NaN" in r.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_walk_accepts_numpy_integer():
|
|
||||||
"""§4.7:`np.int64` 不是 `int` 的子类,注解与运行期都要收(易错点 1)。"""
|
|
||||||
from mcm_toolkit.solve import random_walk
|
|
||||||
|
|
||||||
r = random_walk(n_steps=np.int64(20), n_paths=np.int64(3), random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert r.data.shape == (3, 21)
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_walk_metrics_are_terminal_positions():
|
|
||||||
"""终点位置的标准差按 √t 扩散(t = n_steps)—— 随机游走的核心性质。"""
|
|
||||||
from mcm_toolkit.solve import random_walk
|
|
||||||
|
|
||||||
n_steps = 100
|
|
||||||
r = random_walk(n_steps=n_steps, n_paths=2000, start=5.0, random_state=0)
|
|
||||||
assert abs(r.metrics["mean_terminal"] - 5.0) < 5.0 # 对称游走,期望还在起点附近
|
|
||||||
assert abs(r.metrics["std_terminal"] - np.sqrt(n_steps)) < 5.0 # √t 扩散
|
|
||||||
|
|
||||||
|
|
||||||
def test_random_walk_docstring_example_runs():
|
|
||||||
from mcm_toolkit.solve import random_walk
|
|
||||||
|
|
||||||
r = random_walk(n_steps=100, n_paths=5, random_state=0)
|
|
||||||
assert r.data.shape == (5, 101)
|
|
||||||
assert np.all(np.abs(np.diff(r.data, axis=1)) == 1)
|
|
||||||
|
|
||||||
def test_markov_simulate_shape_start_and_states():
|
|
||||||
"""`data` 形状 (n_paths, steps + 1),第 0 列是初始状态,取值只能是合法状态编号。"""
|
|
||||||
from mcm_toolkit.solve import markov_simulate
|
|
||||||
|
|
||||||
P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
r = markov_simulate(P, initial=0, steps=100, n_paths=5, random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert r.data.shape == (5, 101)
|
|
||||||
assert np.all(r.data[:, 0] == 0)
|
|
||||||
assert set(np.unique(r.data)) <= {0, 1}
|
|
||||||
assert np.issubdtype(r.data.dtype, np.integer)
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_simulate_absorbing_states_never_move():
|
|
||||||
"""单位转移矩阵:状态自锁,整条路径恒等于初始状态(确定性自检)。"""
|
|
||||||
from mcm_toolkit.solve import markov_simulate
|
|
||||||
|
|
||||||
r = markov_simulate(np.eye(2), initial=1, steps=30, n_paths=4, random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert np.all(r.data == 1)
|
|
||||||
assert r.metrics["mean_terminal"] == 1.0
|
|
||||||
assert r.metrics["terminal_distribution"] == {1: 1.0}
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_simulate_is_reproducible():
|
|
||||||
from mcm_toolkit.solve import markov_simulate
|
|
||||||
|
|
||||||
P = np.array([[0.7, 0.3], [0.4, 0.6]])
|
|
||||||
a = markov_simulate(P, initial=0, steps=40, n_paths=6, random_state=11)
|
|
||||||
b = markov_simulate(P, initial=0, steps=40, n_paths=6, random_state=11)
|
|
||||||
c = markov_simulate(P, initial=0, steps=40, n_paths=6, random_state=12)
|
|
||||||
assert np.array_equal(a.data, b.data)
|
|
||||||
assert not np.array_equal(a.data, c.data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_simulate_terminal_frequency_matches_stationary():
|
|
||||||
"""终点频率收敛到平稳分布 [2/3, 1/3] —— 马尔可夫链模拟的核心性质。"""
|
|
||||||
from mcm_toolkit.solve import markov_simulate
|
|
||||||
|
|
||||||
P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
r = markov_simulate(P, initial=0, steps=200, n_paths=20000, random_state=0)
|
|
||||||
freq = np.bincount(r.data[:, -1], minlength=2) / r.data.shape[0]
|
|
||||||
assert np.allclose(freq, [2 / 3, 1 / 3], atol=0.02)
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_simulate_metrics_are_terminal_states():
|
|
||||||
"""`metrics` 只看终点:均值 + `{状态: 频率}`(频率之和为 1、与逐条统计一致)。"""
|
|
||||||
from mcm_toolkit.solve import markov_simulate
|
|
||||||
|
|
||||||
P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
r = markov_simulate(P, initial=0, steps=50, n_paths=500, random_state=3)
|
|
||||||
terminal = r.data[:, -1]
|
|
||||||
dist = r.metrics["terminal_distribution"]
|
|
||||||
assert r.metrics["mean_terminal"] == float(np.mean(terminal))
|
|
||||||
assert np.isclose(sum(dist.values()), 1.0)
|
|
||||||
for state, freq in dist.items():
|
|
||||||
assert np.isclose(freq, float(np.mean(terminal == state)))
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_simulate_terminal_distribution_only_lists_seen_states():
|
|
||||||
"""只含实际出现过的状态(没出现的状态没有 key)—— docstring 已写明这条口径。"""
|
|
||||||
from mcm_toolkit.solve import markov_simulate
|
|
||||||
|
|
||||||
r = markov_simulate(np.eye(3), initial=2, steps=5, n_paths=4, random_state=0)
|
|
||||||
assert r.metrics["terminal_distribution"] == {2: 1.0}
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_simulate_rejects_bad_transition():
|
|
||||||
"""非方阵、行和不为 1 都要在入口直接报错,不能悄悄模拟。"""
|
|
||||||
from mcm_toolkit.solve import markov_simulate
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
markov_simulate(np.array([[0.5, 0.5, 0.0], [0.2, 0.8, 0.0]]), initial=0)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
markov_simulate(np.array([[0.5, 0.4], [0.2, 0.8]]), initial=0)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
markov_simulate(np.array([[1.5, -0.5], [0.0, 1.0]]), initial=0) # 行和是 1,但含负元素
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_simulate_rejects_bad_arguments():
|
|
||||||
"""§4.7:步数 / 路径数的类型与正负都要卡住(bool 先判,易错点 1)。"""
|
|
||||||
from mcm_toolkit.solve import markov_simulate
|
|
||||||
|
|
||||||
P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
markov_simulate(P, initial=0, steps=0)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
markov_simulate(P, initial=0, n_paths=0)
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
markov_simulate(P, initial=0, steps=1.5) # type: ignore[arg-type] # 就是要传错
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
markov_simulate(P, initial=0, n_paths=1.5) # type: ignore[arg-type] # 就是要传错
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
markov_simulate(P, initial=0, steps=True) # type: ignore[arg-type] # 就是要传错
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
markov_simulate(P, initial=0, n_paths=True) # type: ignore[arg-type] # 就是要传错
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_simulate_accepts_numpy_integer():
|
|
||||||
from mcm_toolkit.solve import markov_simulate
|
|
||||||
|
|
||||||
P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
r = markov_simulate(P, initial=0, steps=np.int64(10), n_paths=np.int64(2), random_state=0)
|
|
||||||
assert r.success
|
|
||||||
assert r.data.shape == (2, 11)
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_simulate_docstring_example_runs():
|
|
||||||
from mcm_toolkit.solve import markov_simulate
|
|
||||||
|
|
||||||
P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
r = markov_simulate(P, initial=0, steps=50, n_paths=4, random_state=0)
|
|
||||||
assert r.data.shape == (4, 51)
|
|
||||||
assert np.all(r.data[:, 0] == 0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_steady_state_two_state_chain():
|
|
||||||
"""P=[[0.9,0.1],[0.2,0.8]] 的平稳分布是 [2/3, 1/3]。"""
|
|
||||||
from mcm_toolkit.solve import markov_steady_state
|
|
||||||
|
|
||||||
r = markov_steady_state(np.array([[0.9, 0.1], [0.2, 0.8]]))
|
|
||||||
assert r.success
|
|
||||||
assert np.allclose(r.data, [2 / 3, 1 / 3], atol=1e-6)
|
|
||||||
assert r.metrics["converged"] is True
|
|
||||||
assert r.metrics["iterations"] > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_steady_state_matches_eigenvector():
|
|
||||||
"""与特征分解对拍:取特征值 1 对应的左特征向量(只是检验手段,实现仍走迭代)。"""
|
|
||||||
from mcm_toolkit.solve import markov_steady_state
|
|
||||||
|
|
||||||
P = np.array([[0.5, 0.25, 0.25], [0.1, 0.8, 0.1], [0.2, 0.2, 0.6]])
|
|
||||||
eigenvalues, eigenvectors = np.linalg.eig(P.T)
|
|
||||||
ref = np.real(eigenvectors[:, int(np.argmin(np.abs(eigenvalues - 1.0)))])
|
|
||||||
ref = ref / ref.sum()
|
|
||||||
|
|
||||||
r = markov_steady_state(P)
|
|
||||||
assert np.allclose(r.data, ref, atol=1e-6)
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_steady_state_is_a_distribution():
|
|
||||||
from mcm_toolkit.solve import markov_steady_state
|
|
||||||
|
|
||||||
P = np.array([[0.7, 0.2, 0.1], [0.1, 0.6, 0.3], [0.05, 0.15, 0.8]])
|
|
||||||
r = markov_steady_state(P)
|
|
||||||
assert r.data.shape == (3,)
|
|
||||||
assert np.isclose(float(np.sum(r.data)), 1.0)
|
|
||||||
assert np.all(r.data >= 0.0)
|
|
||||||
assert r.data.dtype == np.float64
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_steady_state_slow_mixing_needs_more_iterations():
|
|
||||||
"""慢混合链:迭代次数显著变多,但结果仍要对得上特征向量(tol 是相邻差口径)。"""
|
|
||||||
from mcm_toolkit.solve import markov_steady_state
|
|
||||||
|
|
||||||
P = np.array([[0.999, 0.001], [0.02, 0.98]])
|
|
||||||
r = markov_steady_state(P)
|
|
||||||
assert r.metrics["iterations"] > 100
|
|
||||||
assert np.allclose(r.data, [0.9523809524, 0.0476190476], atol=1e-6)
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_steady_state_accepts_numpy_integer():
|
|
||||||
"""§4.7:`np.int64` 不是 `int` 的子类,注解与运行期都要收(易错点 1)。"""
|
|
||||||
from mcm_toolkit.solve import markov_steady_state
|
|
||||||
|
|
||||||
P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
r = markov_steady_state(P, max_iter=np.int64(10000))
|
|
||||||
assert r.success
|
|
||||||
assert r.metrics["converged"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_steady_state_rejects_bad_transition():
|
|
||||||
from mcm_toolkit.solve import markov_steady_state
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
markov_steady_state(np.ones((2, 3)) / 3) # 形状不对 → TypeError(口径见决策 57)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
markov_steady_state(np.array([[0.5, 0.4], [0.2, 0.8]]))
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
markov_steady_state(np.array([[1.5, -0.5], [0.0, 1.0]])) # 行和是 1,但含负元素
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_steady_state_non_convergence_is_success_false():
|
|
||||||
"""`max_iter` 不够时按"未收敛"处理:`success=False` 且 `converged=False`(口径 B)。"""
|
|
||||||
from mcm_toolkit.solve import markov_steady_state
|
|
||||||
|
|
||||||
P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
r = markov_steady_state(P, max_iter=3)
|
|
||||||
assert r.success is False
|
|
||||||
assert r.metrics["converged"] is False
|
|
||||||
assert r.metrics["iterations"] == 3
|
|
||||||
assert "未在最大迭代次数内收敛" in r.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_steady_state_rejects_bad_arguments():
|
|
||||||
"""取值错一律 `ValueError`(类型错要不要拆 `TypeError` 待定,见 STATUS 决策 57)。"""
|
|
||||||
from mcm_toolkit.solve import markov_steady_state
|
|
||||||
|
|
||||||
P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
markov_steady_state(P, max_iter=0)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
markov_steady_state(P, tol=0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_markov_steady_state_docstring_example_runs():
|
|
||||||
from mcm_toolkit.solve import markov_steady_state
|
|
||||||
|
|
||||||
P = np.array([[0.9, 0.1], [0.2, 0.8]])
|
|
||||||
r = markov_steady_state(P)
|
|
||||||
assert bool(np.allclose(r.data, [2 / 3, 1 / 3], atol=1e-6))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
|
||||||
def test_knapsack_dp_known_optimum():
|
|
||||||
from mcm_toolkit.solve import knapsack_dp
|
|
||||||
|
|
||||||
r = knapsack_dp(weights=[1, 3, 4, 5], values=[1, 4, 5, 7], capacity=7)
|
|
||||||
assert np.isclose(r.metrics["total_value"], 9.0)
|
|
||||||
assert np.isclose(r.metrics["total_weight"], 7.0)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
|
||||||
def test_linear_program_basic():
|
|
||||||
from mcm_toolkit.solve import linear_program
|
|
||||||
|
|
||||||
r = linear_program(c=[1, 2], A_ub=[[1, 1]], b_ub=[4], bounds=[(0, None)] * 2)
|
|
||||||
assert r.success
|
|
||||||
assert np.isclose(r.metrics["objective"], 4.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mm1_metrics_closed_form():
|
|
||||||
"""与闭式公式逐项对拍(ρ = λ/μ,L = ρ/(1-ρ),Wq = ρ/(μ-λ),W = Wq + 1/μ)。"""
|
|
||||||
from mcm_toolkit.solve import mm1_metrics
|
|
||||||
|
|
||||||
arrival_rate, service_rate = 2, 3
|
|
||||||
rho = arrival_rate / service_rate
|
|
||||||
r = mm1_metrics(arrival_rate=arrival_rate, service_rate=service_rate)
|
|
||||||
|
|
||||||
assert r.success
|
|
||||||
assert np.isclose(r.metrics["rho"], rho, rtol=1e-12)
|
|
||||||
assert np.isclose(r.metrics["L"], rho / (1 - rho), rtol=1e-12)
|
|
||||||
assert np.isclose(r.metrics["Lq"], rho**2 / (1 - rho), rtol=1e-12)
|
|
||||||
assert np.isclose(r.metrics["W"], 1 / (service_rate - arrival_rate), rtol=1e-12)
|
|
||||||
assert np.isclose(r.metrics["Wq"], rho / (service_rate - arrival_rate), rtol=1e-12)
|
|
||||||
assert np.isclose(r.metrics["W"], r.metrics["Wq"] + 1 / service_rate, rtol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mm1_rejects_unstable_system():
|
|
||||||
"""λ >= μ 时系统不稳定,应 success=False 而不是返回 inf(λ == μ 也要走这一支)。"""
|
|
||||||
from mcm_toolkit.solve import mm1_metrics
|
|
||||||
|
|
||||||
equal = mm1_metrics(arrival_rate=3, service_rate=3)
|
|
||||||
assert equal.success is False
|
|
||||||
assert "不稳定" in equal.message
|
|
||||||
assert equal.metrics == {"rho": 1.0} # 失败时也把 rho 报出来(决策 58 的 ④)
|
|
||||||
assert equal.data is None
|
|
||||||
|
|
||||||
assert mm1_metrics(arrival_rate=3, service_rate=2).success is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_mm1_allows_zero_arrival_rate_as_empty_system():
|
|
||||||
"""λ = 0 是合法退化解:空系统(L = Lq = Wq = 0,W 取极限值 1/μ)。"""
|
|
||||||
from mcm_toolkit.solve import mm1_metrics
|
|
||||||
|
|
||||||
r = mm1_metrics(arrival_rate=0, service_rate=3)
|
|
||||||
assert r.success
|
|
||||||
assert r.metrics["rho"] == 0.0
|
|
||||||
assert r.metrics["L"] == 0.0
|
|
||||||
assert r.metrics["Lq"] == 0.0
|
|
||||||
assert r.metrics["Wq"] == 0.0
|
|
||||||
assert np.isclose(r.metrics["W"], 1 / 3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mm1_rejects_bad_arguments():
|
|
||||||
"""λ 非负、μ 必须为正;bool 与非数值都要在入口拦下。"""
|
|
||||||
from mcm_toolkit.solve import mm1_metrics
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
mm1_metrics(arrival_rate=-1, service_rate=3)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
mm1_metrics(arrival_rate=2, service_rate=0)
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
mm1_metrics(arrival_rate=True, service_rate=3)
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
mm1_metrics(arrival_rate=2, service_rate="3") # type: ignore[arg-type] # 就是要传错
|
|
||||||
|
|
||||||
|
|
||||||
def test_mm1_docstring_example_runs():
|
|
||||||
from mcm_toolkit.solve import mm1_metrics
|
|
||||||
|
|
||||||
r = mm1_metrics(arrival_rate=2, service_rate=3)
|
|
||||||
assert round(r.metrics["rho"], 4) == 0.6667
|
|
||||||
|
|
||||||
|
|
||||||
def test_mm1_stability_boundary_is_success_not_exception():
|
|
||||||
"""λ == μ 不能靠 ZeroDivisionError 落到失败分支:必须是显式的"不稳定"口径。"""
|
|
||||||
from mcm_toolkit.solve import mm1_metrics
|
|
||||||
|
|
||||||
r = mm1_metrics(arrival_rate=5, service_rate=5)
|
|
||||||
assert r.success is False
|
|
||||||
assert "ZeroDivisionError" not in r.message
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
|
||||||
def test_simulated_annealing_finds_quadratic_minimum():
|
|
||||||
from mcm_toolkit.solve import simulated_annealing
|
|
||||||
|
|
||||||
f = lambda v: (v[0] - 3.0) ** 2 + (v[1] + 1.0) ** 2 # noqa: E731
|
|
||||||
r = simulated_annealing(f, x0=[0.0, 0.0], n_iter=5000, random_state=0)
|
|
||||||
assert np.allclose(r.params["x"], [3.0, -1.0], atol=0.1)
|
|
||||||
@@ -1,602 +0,0 @@
|
|||||||
"""stats 包测试。
|
|
||||||
|
|
||||||
原则(见 docs/02-architecture.md §5):**测已知解析解,不测实现细节**。
|
|
||||||
|
|
||||||
未实现的函数**逐条** `skip`——**不要**用模块级 `pytestmark`:那会把同文件里
|
|
||||||
已经写好的函数一起跳过,绿灯就是假的(决策 26 / 个人易错点 9)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L1"
|
|
||||||
|
|
||||||
|
|
||||||
def _pearson_p_from_r(r: float, n: int) -> float:
|
|
||||||
"""Pearson 相关系数的 p 值**解析式**(测解析解,不测实现细节):
|
|
||||||
|
|
||||||
``t = r * sqrt((n - 2) / (1 - r^2))``,``p = 2 * P(T > |t|)``,``T ~ t(n - 2)``。
|
|
||||||
"""
|
|
||||||
from scipy import stats as sps
|
|
||||||
|
|
||||||
t_stat = r * np.sqrt((n - 2) / (1 - r**2))
|
|
||||||
return float(2 * sps.t.sf(abs(t_stat), n - 2))
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_ci_matches_normal_theory_on_large_n(rng):
|
|
||||||
"""大样本下 bootstrap 百分位区间应逼近正态解析区间(±10% 容差)。"""
|
|
||||||
from mcm_toolkit.stats import bootstrap_ci
|
|
||||||
|
|
||||||
x = rng.normal(10, 2, size=5000)
|
|
||||||
r = bootstrap_ci(x, statistic=np.mean, confidence=0.95, n_resamples=5000, random_state=0)
|
|
||||||
se = x.std(ddof=1) / np.sqrt(x.size)
|
|
||||||
assert abs((r.params["high"] - r.params["low"]) - 2 * 1.96 * se) < 0.1 * 2 * 1.96 * se
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_ci_works_for_arbitrary_statistic(rng):
|
|
||||||
"""bootstrap 的核心价值:任意统计量都能给区间(这里用中位数)。"""
|
|
||||||
from mcm_toolkit.stats import bootstrap_ci
|
|
||||||
|
|
||||||
x = rng.lognormal(0, 1, size=2000)
|
|
||||||
r = bootstrap_ci(x, statistic=np.median, random_state=0)
|
|
||||||
assert r.params["low"] < r.params["high"]
|
|
||||||
assert r.params["low"] < np.median(x) < r.params["high"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_ci_is_reproducible(rng):
|
|
||||||
from mcm_toolkit.stats import bootstrap_ci
|
|
||||||
|
|
||||||
x = rng.normal(size=500)
|
|
||||||
a = bootstrap_ci(x, statistic=np.mean, random_state=123)
|
|
||||||
b = bootstrap_ci(x, statistic=np.mean, random_state=123)
|
|
||||||
assert a.params["low"] == b.params["low"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_ci_reports_the_original_estimate(rng):
|
|
||||||
"""`params["estimate"]` 必须是**原样本**上的统计量,不是重采样分布的均值。"""
|
|
||||||
from mcm_toolkit.stats import bootstrap_ci
|
|
||||||
|
|
||||||
x = rng.normal(10, 2, size=200)
|
|
||||||
r = bootstrap_ci(x, statistic=np.mean, n_resamples=500, random_state=0)
|
|
||||||
|
|
||||||
assert r.params["estimate"] == pytest.approx(float(np.mean(x)))
|
|
||||||
assert set(r.params) == {"estimate", "low", "high", "confidence", "n_resamples"}
|
|
||||||
assert r.params["n_resamples"] == 500
|
|
||||||
assert r.metrics["statistic_name"] == "mean"
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_ci_interval_widens_with_confidence(rng):
|
|
||||||
"""置信水平越高区间必须越宽 —— 结构性检查,不依赖任何具体分布。"""
|
|
||||||
from mcm_toolkit.stats import bootstrap_ci
|
|
||||||
|
|
||||||
x = rng.normal(size=300)
|
|
||||||
width = [
|
|
||||||
bootstrap_ci(x, np.mean, confidence=c, n_resamples=2000, random_state=7).params["high"]
|
|
||||||
- bootstrap_ci(x, np.mean, confidence=c, n_resamples=2000, random_state=7).params["low"]
|
|
||||||
for c in (0.80, 0.95)
|
|
||||||
]
|
|
||||||
assert width[0] < width[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_ci_accepts_the_documented_containers(rng):
|
|
||||||
"""DataFrame 只取数值列后摊平;Series / ndarray / list 都收。"""
|
|
||||||
from mcm_toolkit.stats import bootstrap_ci
|
|
||||||
|
|
||||||
x = rng.normal(size=200)
|
|
||||||
ref = bootstrap_ci(x, np.mean, n_resamples=500, random_state=0).params
|
|
||||||
|
|
||||||
for wrapped in (pd.Series(x), pd.DataFrame({"a": x}), list(x), tuple(x)):
|
|
||||||
got = bootstrap_ci(wrapped, np.mean, n_resamples=500, random_state=0).params
|
|
||||||
assert got["low"] == pytest.approx(ref["low"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_ci_works_for_a_custom_lambda(rng):
|
|
||||||
"""不认识的统计量走逐样本回退分支(这里是极差)。"""
|
|
||||||
from mcm_toolkit.stats import bootstrap_ci
|
|
||||||
|
|
||||||
x = rng.normal(size=200)
|
|
||||||
r = bootstrap_ci(x, statistic=lambda s: float(s.max() - s.min()), n_resamples=500, random_state=0)
|
|
||||||
|
|
||||||
# 极差的重采样值**永远不会超过原样本极差**(原样本本身就是上界),
|
|
||||||
# 所以百分位区间的上界会正好落在 estimate 上,不能再断言 estimate < high。
|
|
||||||
assert r.params["estimate"] == pytest.approx(float(x.max() - x.min()))
|
|
||||||
assert r.params["low"] > 0
|
|
||||||
assert r.params["low"] < r.params["high"] <= r.params["estimate"]
|
|
||||||
assert r.metrics["statistic_name"] == "custom"
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_ci_follows_get_rng_when_no_seed_is_given(rng):
|
|
||||||
"""不给种子时必须跟随 `get_rng` 的模块级生成器(决策 24),不能另开一个 default_rng。"""
|
|
||||||
from mcm_toolkit.core import set_seed
|
|
||||||
from mcm_toolkit.stats import bootstrap_ci
|
|
||||||
|
|
||||||
x = rng.normal(size=200)
|
|
||||||
set_seed(0)
|
|
||||||
first = bootstrap_ci(x, np.mean, n_resamples=300).params["low"]
|
|
||||||
set_seed(0)
|
|
||||||
second = bootstrap_ci(x, np.mean, n_resamples=300).params["low"]
|
|
||||||
|
|
||||||
assert first == second
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_ci_rejects_unknown_method(rng):
|
|
||||||
from mcm_toolkit.stats import bootstrap_ci
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="bogus"):
|
|
||||||
bootstrap_ci(rng.normal(size=50), np.mean, method="bogus", n_resamples=10)
|
|
||||||
with pytest.raises(NotImplementedError):
|
|
||||||
bootstrap_ci(rng.normal(size=50), np.mean, method="bca", n_resamples=10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_confidence_interval_matches_scipy(rng):
|
|
||||||
from scipy import stats
|
|
||||||
|
|
||||||
from mcm_toolkit.stats import confidence_interval
|
|
||||||
|
|
||||||
x = rng.normal(5, 1, size=50)
|
|
||||||
lo, hi = confidence_interval(x, confidence=0.95, method="t")
|
|
||||||
ref = stats.t.interval(0.95, len(x) - 1, loc=x.mean(), scale=stats.sem(x))
|
|
||||||
assert np.allclose([lo, hi], ref, rtol=1e-6)
|
|
||||||
|
|
||||||
|
|
||||||
def test_confidence_interval_normal_matches_z_formula(rng):
|
|
||||||
"""normal 走 z 解析式:`mean ± z·s/√n`,s 用**样本**标准差(ddof=1)。"""
|
|
||||||
from scipy import stats as sps
|
|
||||||
|
|
||||||
from mcm_toolkit.stats import confidence_interval
|
|
||||||
|
|
||||||
x = rng.normal(8.5, 1, size=200)
|
|
||||||
lo, hi = confidence_interval(x, confidence=0.95, method="normal")
|
|
||||||
|
|
||||||
half = float(sps.norm.ppf(0.975)) * x.std(ddof=1) / np.sqrt(len(x))
|
|
||||||
assert lo == pytest.approx(x.mean() - half)
|
|
||||||
assert hi == pytest.approx(x.mean() + half)
|
|
||||||
|
|
||||||
|
|
||||||
def test_confidence_interval_mean_is_not_clamped_to_unit_range(rng):
|
|
||||||
"""均值区间不该被夹到 [0, 1]:平均身高 170cm 的下界不可能是 0。"""
|
|
||||||
from mcm_toolkit.stats import confidence_interval
|
|
||||||
|
|
||||||
x = rng.normal(170, 8, size=50)
|
|
||||||
lo, hi = confidence_interval(x, method="t")
|
|
||||||
|
|
||||||
assert lo > 100.0
|
|
||||||
assert lo < hi
|
|
||||||
|
|
||||||
|
|
||||||
def test_confidence_interval_proportion_stays_in_unit_interval():
|
|
||||||
"""比例的区间必须落在 [0, 1] 里 —— 只有这一种 method 需要夹。"""
|
|
||||||
from mcm_toolkit.stats import confidence_interval
|
|
||||||
|
|
||||||
y = [0.0, 1.0, 1.0, 1.0, 0.0, 1.0]
|
|
||||||
lo, hi = confidence_interval(y, method="proportion")
|
|
||||||
|
|
||||||
assert 0.0 <= lo < hi <= 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_confidence_interval_accepts_documented_containers():
|
|
||||||
"""docstring 的 Examples 传 list、L0 用例传 ndarray,两边都得收。"""
|
|
||||||
from mcm_toolkit.stats import confidence_interval
|
|
||||||
|
|
||||||
data = [1.0, 2.0, 3.0, 4.0, 5.0]
|
|
||||||
expected = confidence_interval(data)
|
|
||||||
|
|
||||||
assert confidence_interval(pd.Series(data)) == pytest.approx(expected)
|
|
||||||
assert confidence_interval(np.asarray(data)) == pytest.approx(expected)
|
|
||||||
assert confidence_interval(pd.DataFrame({"x": data})) == pytest.approx(expected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_confidence_interval_rejects_bad_input(rng):
|
|
||||||
from mcm_toolkit.stats import confidence_interval
|
|
||||||
|
|
||||||
x = rng.normal(size=20)
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
confidence_interval("abc")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
confidence_interval(x, method="bogus")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
confidence_interval(x, confidence=1.5)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
confidence_interval(x, confidence=0.0)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
confidence_interval(pd.DataFrame({"city": ["X", "Y"]}))
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
confidence_interval([1.0])
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------ stats: t_test
|
|
||||||
|
|
||||||
|
|
||||||
def test_t_test_one_sample_matches_analytic_formula(rng):
|
|
||||||
"""单样本:`t = (mean - value) / (s/√n)`,`df = n - 1`,`p = 2·P(T > |t|)`。"""
|
|
||||||
from scipy import stats as sps
|
|
||||||
|
|
||||||
from mcm_toolkit.stats import t_test
|
|
||||||
|
|
||||||
x = rng.normal(5, 1, size=30)
|
|
||||||
r = t_test(x, value=5.0)
|
|
||||||
|
|
||||||
t_stat = (x.mean() - 5.0) / (x.std(ddof=1) / np.sqrt(len(x)))
|
|
||||||
assert r["statistic"] == pytest.approx(t_stat)
|
|
||||||
assert r["pvalue"] == pytest.approx(float(2 * sps.t.sf(abs(t_stat), len(x) - 1)))
|
|
||||||
assert r["df"] == pytest.approx(29.0)
|
|
||||||
assert r["significant"] == (r["pvalue"] < 0.05)
|
|
||||||
assert isinstance(r["conclusion"], str) and r["conclusion"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_t_test_two_sample_welch_df_matches_formula(rng):
|
|
||||||
"""默认 Welch:`df` 用 Welch–Satterthwaite 公式解析对照。"""
|
|
||||||
from mcm_toolkit.stats import t_test
|
|
||||||
|
|
||||||
a = rng.normal(0, 1, size=25)
|
|
||||||
b = rng.normal(1, 4, size=35)
|
|
||||||
r = t_test(a, b)
|
|
||||||
|
|
||||||
v1, v2 = a.var(ddof=1), b.var(ddof=1)
|
|
||||||
n1, n2 = len(a), len(b)
|
|
||||||
expected_df = (v1 / n1 + v2 / n2) ** 2 / ((v1 / n1) ** 2 / (n1 - 1) + (v2 / n2) ** 2 / (n2 - 1))
|
|
||||||
assert r["df"] == pytest.approx(expected_df)
|
|
||||||
assert r["pvalue"] < 0.05
|
|
||||||
|
|
||||||
|
|
||||||
def test_t_test_equal_var_uses_pooled_df(rng):
|
|
||||||
from mcm_toolkit.stats import t_test
|
|
||||||
|
|
||||||
a = rng.normal(0, 1, size=25)
|
|
||||||
b = rng.normal(1, 1, size=35)
|
|
||||||
|
|
||||||
assert t_test(a, b, equal_var=True)["df"] == pytest.approx(58.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_t_test_paired_reports_key_set_and_df(rng):
|
|
||||||
from mcm_toolkit.stats import t_test
|
|
||||||
|
|
||||||
a = rng.normal(size=20)
|
|
||||||
r = t_test(a, a + rng.normal(0, 0.1, 20), paired=True)
|
|
||||||
|
|
||||||
assert set(r) == {"statistic", "pvalue", "df", "significant", "conclusion"}
|
|
||||||
assert r["df"] == pytest.approx(19.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_t_test_rejects_bad_input(rng):
|
|
||||||
from mcm_toolkit.stats import t_test
|
|
||||||
|
|
||||||
a = rng.normal(size=20)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
t_test(a, alpha=1.5)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
t_test([1.0])
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
t_test(a, rng.normal(size=19), paired=True)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
t_test(pd.DataFrame({"city": ["X", "Y"]}))
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
t_test("abc")
|
|
||||||
|
|
||||||
|
|
||||||
def test_correlation_matrix_diagonal_is_one(rng):
|
|
||||||
from mcm_toolkit.stats import correlation_matrix
|
|
||||||
|
|
||||||
x = pd.DataFrame(rng.normal(size=(200, 4)), columns=["a", "b", "c", "d"])
|
|
||||||
c = correlation_matrix(x)
|
|
||||||
|
|
||||||
assert np.allclose(np.diag(np.asarray(c)), 1.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_correlation_matrix_is_numeric_and_symmetric(rng):
|
|
||||||
"""方案 A:返回**数值**矩阵(不是带星标的字符串表),能直接参与后续计算。"""
|
|
||||||
from mcm_toolkit.stats import correlation_matrix
|
|
||||||
|
|
||||||
x = pd.DataFrame(rng.normal(size=(200, 3)), columns=["a", "b", "c"])
|
|
||||||
c = correlation_matrix(x)
|
|
||||||
|
|
||||||
assert all(np.issubdtype(dtype, np.floating) for dtype in c.dtypes)
|
|
||||||
assert np.allclose(np.asarray(c), np.asarray(x.corr(method="pearson", min_periods=1)))
|
|
||||||
assert np.allclose(np.asarray(c), np.asarray(c.T))
|
|
||||||
|
|
||||||
|
|
||||||
def test_correlation_matrix_runs_on_multicolumn_frame(rng):
|
|
||||||
"""回归:`if numeric_cols == 0` 拿 Index 跟 0 比,多列数值表当场 ValueError 崩掉。"""
|
|
||||||
from mcm_toolkit.stats import correlation_matrix
|
|
||||||
|
|
||||||
x = pd.DataFrame(rng.normal(size=(50, 3)), columns=["a", "b", "c"])
|
|
||||||
out = correlation_matrix(x)
|
|
||||||
|
|
||||||
assert out.shape == (3, 3)
|
|
||||||
assert list(out.columns) == ["a", "b", "c"]
|
|
||||||
assert list(out.index) == ["a", "b", "c"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_correlation_matrix_skips_non_numeric_columns(rng):
|
|
||||||
from mcm_toolkit.stats import correlation_matrix
|
|
||||||
|
|
||||||
x = pd.DataFrame({"a": rng.normal(size=30), "b": rng.normal(size=30), "city": ["X"] * 30})
|
|
||||||
assert list(correlation_matrix(x).columns) == ["a", "b"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_correlation_matrix_rejects_bad_input():
|
|
||||||
from mcm_toolkit.stats import correlation_matrix
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
correlation_matrix([[1.0, 2.0]])
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
correlation_matrix(pd.DataFrame({"city": ["X", "Y"]}))
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
correlation_matrix(pd.DataFrame({"a": [1.0, 2.0]}), method="bogus") # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_correlation_pvalues_matches_analytic_formula(rng):
|
|
||||||
from mcm_toolkit.stats import correlation_matrix, correlation_pvalues
|
|
||||||
|
|
||||||
x = pd.DataFrame(rng.normal(size=(80, 3)), columns=["a", "b", "c"])
|
|
||||||
corr = correlation_matrix(x)
|
|
||||||
p = correlation_pvalues(x)
|
|
||||||
|
|
||||||
for i in ["a", "b", "c"]:
|
|
||||||
for j in ["a", "b", "c"]:
|
|
||||||
if i == j:
|
|
||||||
continue
|
|
||||||
assert p.loc[i, j] == pytest.approx(_pearson_p_from_r(corr.loc[i, j], 80))
|
|
||||||
|
|
||||||
|
|
||||||
def test_correlation_pvalues_diagonal_is_nan(rng):
|
|
||||||
"""对角线写 0 会被下游读成"极显著";"自己和自己相关"没有 p 值,必须是 NaN。"""
|
|
||||||
from mcm_toolkit.stats import correlation_pvalues
|
|
||||||
|
|
||||||
p = correlation_pvalues(pd.DataFrame(rng.normal(size=(50, 2)), columns=["a", "b"]))
|
|
||||||
|
|
||||||
assert np.isnan(p.loc["a", "a"])
|
|
||||||
assert np.isnan(p.loc["b", "b"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_correlation_pvalues_uses_the_same_observations_as_r(rng):
|
|
||||||
"""含缺失时 p 要和 r 用**同一批**观测:成对删缺失,而不是整行删、也不是干脆不删。"""
|
|
||||||
from mcm_toolkit.stats import correlation_pvalues
|
|
||||||
|
|
||||||
a = rng.normal(size=40)
|
|
||||||
b = rng.normal(size=40)
|
|
||||||
a[[3, 7, 11]] = np.nan
|
|
||||||
x = pd.DataFrame({"a": a, "b": b})
|
|
||||||
|
|
||||||
pair = x.dropna()
|
|
||||||
r = float(np.corrcoef(pair["a"], pair["b"])[0, 1])
|
|
||||||
|
|
||||||
assert correlation_pvalues(x).loc["a", "b"] == pytest.approx(_pearson_p_from_r(r, len(pair)))
|
|
||||||
|
|
||||||
|
|
||||||
def test_correlation_pvalues_respects_min_periods(rng):
|
|
||||||
from mcm_toolkit.stats import correlation_pvalues
|
|
||||||
|
|
||||||
a = rng.normal(size=10)
|
|
||||||
a[:5] = np.nan
|
|
||||||
x = pd.DataFrame({"a": a, "b": rng.normal(size=10)})
|
|
||||||
|
|
||||||
assert np.isnan(correlation_pvalues(x, min_periods=6).loc["a", "b"])
|
|
||||||
assert np.isfinite(correlation_pvalues(x, min_periods=5).loc["a", "b"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_correlation_table_diagonal_has_no_stars():
|
|
||||||
"""对角线是"自己和自己",不该有显著性星标;完全负相关则必然是 ***。"""
|
|
||||||
from mcm_toolkit.stats import correlation_table
|
|
||||||
|
|
||||||
x = pd.DataFrame({"a": np.arange(30.0), "b": np.arange(30.0) * -2.0})
|
|
||||||
table = correlation_table(x)
|
|
||||||
|
|
||||||
assert table.loc["a", "a"] == "1.000"
|
|
||||||
assert table.loc["a", "b"] == "-1.000***"
|
|
||||||
|
|
||||||
|
|
||||||
def test_correlation_table_matches_the_numeric_outputs(rng):
|
|
||||||
from mcm_toolkit.stats import correlation_matrix, correlation_pvalues, correlation_table
|
|
||||||
|
|
||||||
x = pd.DataFrame(rng.normal(size=(60, 3)), columns=["a", "b", "c"])
|
|
||||||
corr = correlation_matrix(x)
|
|
||||||
p = correlation_pvalues(x)
|
|
||||||
table = correlation_table(x)
|
|
||||||
|
|
||||||
assert list(table.columns) == list(corr.columns)
|
|
||||||
assert table.loc["a", "a"] == "1.000"
|
|
||||||
assert np.isnan(p.loc["a", "a"])
|
|
||||||
|
|
||||||
pv = p.loc["a", "b"]
|
|
||||||
stars = "***" if pv < 0.001 else "**" if pv < 0.01 else "*" if pv < 0.05 else ""
|
|
||||||
value = corr.loc["a", "b"]
|
|
||||||
assert table.loc["a", "b"] == f"{value:.3f}" + stars
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------ stats: describe
|
|
||||||
|
|
||||||
|
|
||||||
def test_describe_reports_expected_keys(rng):
|
|
||||||
from mcm_toolkit.stats import describe
|
|
||||||
|
|
||||||
table = describe(rng.normal(size=(100, 3)))
|
|
||||||
for key in ["mean", "std", "min", "max", "median"]:
|
|
||||||
assert key in table.index
|
|
||||||
|
|
||||||
|
|
||||||
def test_describe_rows_are_metrics_and_columns_are_variables():
|
|
||||||
"""方向约定:行 = 指标,列 = 变量。多转置一次就反了。"""
|
|
||||||
from mcm_toolkit.stats import describe
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, 2.0, 3.0], "b": [10.0, 20.0, 30.0]})
|
|
||||||
table = describe(df)
|
|
||||||
|
|
||||||
assert list(table.columns) == ["a", "b"]
|
|
||||||
assert table.loc["mean", "a"] == pytest.approx(2.0)
|
|
||||||
assert table.loc["max", "b"] == pytest.approx(30.0)
|
|
||||||
assert table.loc["median", "b"] == pytest.approx(20.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_describe_missing_and_shape_rows_are_not_nan():
|
|
||||||
"""曾经的静默 bug:转置后 `.loc[指标名] = <按变量索引的 Series>` 对不上列,
|
|
||||||
整行会被填成 NaN —— 不报错,但数据全丢。"""
|
|
||||||
from mcm_toolkit.stats import describe
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, np.nan, 3.0, 4.0], "b": [1.0, 2.0, 3.0, 4.0]})
|
|
||||||
table = describe(df)
|
|
||||||
|
|
||||||
assert table.loc["missing", "a"] == pytest.approx(1.0)
|
|
||||||
assert table.loc["missing", "b"] == pytest.approx(0.0)
|
|
||||||
assert table.loc["missing_ratio", "a"] == pytest.approx(0.25)
|
|
||||||
assert table.loc["missing_ratio", "b"] == pytest.approx(0.0)
|
|
||||||
assert table.loc["skewness", "b"] == pytest.approx(0.0, abs=1e-12)
|
|
||||||
extra = table.loc[["missing", "missing_ratio", "skewness", "kurtosis"]]
|
|
||||||
assert not bool(extra.isna().all(axis=None))
|
|
||||||
|
|
||||||
|
|
||||||
def test_describe_skips_non_numeric_columns():
|
|
||||||
from mcm_toolkit.stats import describe
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, 2.0], "city": ["X", "Y"]})
|
|
||||||
table = describe(df)
|
|
||||||
|
|
||||||
assert list(table.columns) == ["a"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_describe_accepts_ndarray_list_and_series(rng):
|
|
||||||
"""兄弟函数都收 ndarray / list,describe 没理由只收 DataFrame。"""
|
|
||||||
from mcm_toolkit.stats import describe
|
|
||||||
|
|
||||||
assert "mean" in describe(rng.normal(size=(50, 2))).index
|
|
||||||
assert "mean" in describe([[1.0, 2.0], [3.0, 4.0]]).index
|
|
||||||
assert "mean" in describe(pd.Series([1.0, 2.0, 3.0])).index
|
|
||||||
|
|
||||||
|
|
||||||
def test_describe_weighted_rows_match_manual_formula():
|
|
||||||
from mcm_toolkit.stats import describe
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, 2.0, 3.0]})
|
|
||||||
w = np.array([1.0, 1.0, 2.0])
|
|
||||||
table = describe(df, weights=w)
|
|
||||||
|
|
||||||
expect_mean = np.average(df["a"], weights=w)
|
|
||||||
expect_std = np.sqrt(np.average((df["a"] - expect_mean) ** 2, weights=w))
|
|
||||||
assert table.loc["weighted_mean", "a"] == pytest.approx(expect_mean)
|
|
||||||
assert table.loc["weighted_std", "a"] == pytest.approx(expect_std)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("weights", "exc"),
|
|
||||||
[
|
|
||||||
(np.ones(2), ValueError),
|
|
||||||
(np.zeros(3), ValueError),
|
|
||||||
(np.array([1.0, -1.0, 1.0]), ValueError),
|
|
||||||
(np.array([1.0, np.nan, 1.0]), ValueError),
|
|
||||||
("abc", TypeError),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_describe_rejects_bad_weights(weights, exc):
|
|
||||||
from mcm_toolkit.stats import describe
|
|
||||||
|
|
||||||
with pytest.raises(exc):
|
|
||||||
describe(pd.DataFrame({"a": [1.0, 2.0, 3.0]}), weights=weights)
|
|
||||||
|
|
||||||
|
|
||||||
def test_describe_rejects_unsupported_type_and_all_text_frame():
|
|
||||||
from mcm_toolkit.stats import describe
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
describe({"a": [1.0]})
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
describe(pd.DataFrame({"city": ["X", "Y"]}))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------ stats: zscore
|
|
||||||
|
|
||||||
|
|
||||||
def test_zscore_matches_manual_formula():
|
|
||||||
from mcm_toolkit.stats import zscore
|
|
||||||
|
|
||||||
x = np.array([1.0, 2.0, 3.0, 4.0])
|
|
||||||
assert np.allclose(np.asarray(zscore(x)), (x - x.mean()) / x.std())
|
|
||||||
|
|
||||||
|
|
||||||
def test_zscore_is_per_column_and_keeps_frame(rng):
|
|
||||||
from mcm_toolkit.stats import zscore
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": rng.normal(size=200), "b": rng.normal(5, 3, size=200)})
|
|
||||||
out = zscore(df)
|
|
||||||
|
|
||||||
assert isinstance(out, pd.DataFrame)
|
|
||||||
assert list(out.columns) == ["a", "b"]
|
|
||||||
assert np.allclose(np.asarray(out).mean(axis=0), 0.0, atol=1e-12)
|
|
||||||
assert np.allclose(np.asarray(out).std(axis=0), 1.0, atol=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
def test_zscore_constant_column_stays_zero():
|
|
||||||
from mcm_toolkit.stats import zscore
|
|
||||||
|
|
||||||
assert np.allclose(np.asarray(zscore([2.0, 2.0, 2.0])), 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_zscore_series_keeps_index_and_name(rng):
|
|
||||||
"""Series 进 Series 出,索引和 name 都要带回来 —— 否则管道里 concat 会丢列名。"""
|
|
||||||
from mcm_toolkit.stats import zscore
|
|
||||||
|
|
||||||
s = pd.Series(rng.normal(size=50), index=[f"r{i}" for i in range(50)], name="price")
|
|
||||||
out = zscore(s)
|
|
||||||
|
|
||||||
assert isinstance(out, pd.Series)
|
|
||||||
assert out.name == "price"
|
|
||||||
assert list(out.index) == list(s.index)
|
|
||||||
|
|
||||||
|
|
||||||
def test_zscore_rejects_bad_type_and_all_text_frame():
|
|
||||||
from mcm_toolkit.stats import zscore
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
zscore("abc")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
zscore(pd.DataFrame({"city": ["X"]}))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------- stats: coefficient_of_variation
|
|
||||||
|
|
||||||
|
|
||||||
def test_coefficient_of_variation_matches_manual_formula():
|
|
||||||
from mcm_toolkit.stats import coefficient_of_variation
|
|
||||||
|
|
||||||
x = np.array([1.0, 2.0, 3.0, 4.0])
|
|
||||||
assert coefficient_of_variation(x) == pytest.approx(x.std() / x.mean())
|
|
||||||
|
|
||||||
|
|
||||||
def test_coefficient_of_variation_accepts_single_column_frame():
|
|
||||||
from mcm_toolkit.stats import coefficient_of_variation
|
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, 2.0, 3.0]})
|
|
||||||
single = coefficient_of_variation([1.0, 2.0, 3.0])
|
|
||||||
assert coefficient_of_variation(df) == pytest.approx(single)
|
|
||||||
|
|
||||||
|
|
||||||
def test_coefficient_of_variation_uses_absolute_mean():
|
|
||||||
"""均值为负时 CV 仍是**正数**:符号不该被读成"波动更小"。"""
|
|
||||||
from mcm_toolkit.stats import coefficient_of_variation
|
|
||||||
|
|
||||||
x = np.array([-1.0, -2.0, -3.0, -4.0])
|
|
||||||
assert coefficient_of_variation(x) == pytest.approx(x.std() / abs(x.mean()))
|
|
||||||
assert coefficient_of_variation(x) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_coefficient_of_variation_zero_mean_is_value_error():
|
|
||||||
"""均值为 0 是**取值**问题不是类型问题:必须是 ValueError 而不是 TypeError。"""
|
|
||||||
from mcm_toolkit.stats import coefficient_of_variation
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
coefficient_of_variation([-1.0, 1.0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_coefficient_of_variation_rejects_empty_2d_and_bad_type():
|
|
||||||
from mcm_toolkit.stats import coefficient_of_variation
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
coefficient_of_variation([])
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
coefficient_of_variation([[1.0, 2.0], [3.0, 4.0]])
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
coefficient_of_variation(pd.DataFrame({"a": [1.0], "b": [2.0]}))
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
coefficient_of_variation(5.0)
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"""viz 包测试。
|
|
||||||
|
|
||||||
绘图测试不比对像素——只验证"能画出来、能存成文件、尺寸符合美赛要求"。
|
|
||||||
未实现的函数按决策 26 **逐条** skip,不用模块级 `pytestmark`。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import matplotlib
|
|
||||||
|
|
||||||
matplotlib.use("Agg")
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L0/L1"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fig():
|
|
||||||
"""最小 Figure;测试结束统一关闭,避免 matplotlib 的"图开太多"告警。"""
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
|
|
||||||
figure, _ = plt.subplots(figsize=(2, 2))
|
|
||||||
yield figure
|
|
||||||
plt.close(figure)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
|
||||||
@pytest.mark.parametrize("kind", ["line", "scatter", "hist", "box", "bar"])
|
|
||||||
def test_plot_kinds_produce_figure(kind, rng):
|
|
||||||
from mcm_toolkit.viz import plot
|
|
||||||
|
|
||||||
x = rng.normal(size=100)
|
|
||||||
figure, ax = plot(x, x, kind=kind)
|
|
||||||
assert figure is not None and ax is not None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
|
||||||
def test_mcm_style_sets_300dpi():
|
|
||||||
from mcm_toolkit.viz import mcm_style
|
|
||||||
|
|
||||||
mcm_style()
|
|
||||||
assert matplotlib.rcParams["figure.dpi"] == 300.0
|
|
||||||
assert matplotlib.rcParams["font.size"] >= 8.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_figure_writes_png_and_pdf(fig, tmp_path):
|
|
||||||
from mcm_toolkit.viz import save_figure
|
|
||||||
|
|
||||||
paths = save_figure(fig, tmp_path / "fig1", formats=("png", "pdf"))
|
|
||||||
assert [p.suffix for p in paths] == [".png", ".pdf"]
|
|
||||||
assert all(p.exists() and p.stat().st_size > 0 for p in paths)
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_figure_strips_an_image_suffix_from_path(fig, tmp_path):
|
|
||||||
"""`fig1.png` 当主干用:不能写出 `fig1.png.png`。"""
|
|
||||||
from mcm_toolkit.viz import save_figure
|
|
||||||
|
|
||||||
paths = save_figure(fig, tmp_path / "fig1.png", formats=("pdf",))
|
|
||||||
assert paths == [tmp_path / "fig1.pdf"]
|
|
||||||
assert paths[0].exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_figure_creates_parent_directories(fig, tmp_path):
|
|
||||||
from mcm_toolkit.viz import save_figure
|
|
||||||
|
|
||||||
target = tmp_path / "nested" / "deeper" / "fig1"
|
|
||||||
paths = save_figure(fig, target, formats=("png",))
|
|
||||||
assert paths[0] == tmp_path / "nested" / "deeper" / "fig1.png"
|
|
||||||
assert paths[0].exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_figure_normalizes_format_case_and_keeps_order(fig, tmp_path):
|
|
||||||
from mcm_toolkit.viz import save_figure
|
|
||||||
|
|
||||||
paths = save_figure(fig, tmp_path / "fig1", formats=("PDF", "png"))
|
|
||||||
assert [p.suffix for p in paths] == [".pdf", ".png"]
|
|
||||||
assert all(p.exists() for p in paths)
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_figure_rejects_a_single_string_as_formats(fig, tmp_path):
|
|
||||||
from mcm_toolkit.viz import save_figure
|
|
||||||
|
|
||||||
with pytest.raises(TypeError, match="不要传单个字符串"):
|
|
||||||
save_figure(fig, tmp_path / "fig1", formats="png") # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_figure_rejects_bad_formats(fig, tmp_path):
|
|
||||||
from mcm_toolkit.viz import save_figure
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="不能为空"):
|
|
||||||
save_figure(fig, tmp_path / "fig1", formats=())
|
|
||||||
with pytest.raises(ValueError, match="不支持"):
|
|
||||||
save_figure(fig, tmp_path / "fig1", formats=("gif",))
|
|
||||||
with pytest.raises(ValueError, match="重复"):
|
|
||||||
save_figure(fig, tmp_path / "fig1", formats=("png", "PNG"))
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_figure_validates_before_creating_directories(fig, tmp_path):
|
|
||||||
"""格式错时不能先建目录留下副作用。"""
|
|
||||||
from mcm_toolkit.viz import save_figure
|
|
||||||
|
|
||||||
nested = tmp_path / "should_not_exist" / "fig1"
|
|
||||||
with pytest.raises(ValueError, match="不支持"):
|
|
||||||
save_figure(fig, nested, formats=("gif",))
|
|
||||||
assert not nested.parent.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_figure_rejects_bad_render_options(fig, tmp_path):
|
|
||||||
from mcm_toolkit.viz import save_figure
|
|
||||||
|
|
||||||
with pytest.raises(TypeError, match="dpi"):
|
|
||||||
save_figure(fig, tmp_path / "fig1", dpi=300.0) # type: ignore[arg-type]
|
|
||||||
with pytest.raises(TypeError, match="dpi"):
|
|
||||||
save_figure(fig, tmp_path / "fig1", dpi=True)
|
|
||||||
with pytest.raises(ValueError, match="dpi"):
|
|
||||||
save_figure(fig, tmp_path / "fig1", dpi=0)
|
|
||||||
with pytest.raises(TypeError, match="bbox_inches"):
|
|
||||||
save_figure(fig, tmp_path / "fig1", bbox_inches=3) # type: ignore[arg-type]
|
|
||||||
with pytest.raises(ValueError, match="bbox_inches"):
|
|
||||||
save_figure(fig, tmp_path / "fig1", bbox_inches="")
|
|
||||||
with pytest.raises(TypeError, match="transparent"):
|
|
||||||
save_figure(fig, tmp_path / "fig1", transparent="yes") # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_figure_rejects_non_figure(tmp_path):
|
|
||||||
from mcm_toolkit.viz import save_figure
|
|
||||||
|
|
||||||
with pytest.raises(TypeError, match="Figure"):
|
|
||||||
save_figure(object(), tmp_path / "fig1") # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
|
||||||
def test_save_table_writes_latex_with_caption(tmp_path):
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from mcm_toolkit.viz import save_table
|
|
||||||
|
|
||||||
df = pd.DataFrame({"rmse": [0.31, 0.28]}, index=["linear", "rf"])
|
|
||||||
paths = save_table(df, tmp_path / "tab1", caption="模型对比", label="tab:compare")
|
|
||||||
tex = next(p for p in paths if p.suffix == ".tex")
|
|
||||||
content = tex.read_text(encoding="utf-8")
|
|
||||||
assert "模型对比" in content and "tab:compare" in content
|
|
||||||
Reference in new issue
Block a user