commit 5319589dfdd7a455855df635b814089fdb8e97f6 Author: guzhujushi Date: Thu Oct 1 17:43:48 2026 +0800 chore: 初始化 MCM/ICM Toolkit 仓库骨架 审查并精简 ChatGPT 的建库建议后落地的第一版骨架,只定接口不写实现。 - 建议审查:14 个包 → 7 个;约 250 个函数 → 约 40 个;5 个 Result 类 → 1 个 (docs/01-suggestion-review.md) - docs/:架构、路线图(含永久不做清单)、环境、Git 工作流、函数卡片模板 - src/mcm_toolkit/:core / data / stats / models / solve / decision / viz 共 23 个模块,均为签名 + docstring + NotImplementedError - tests/:9 个文件,其中 test_api_surface.py 已通过(20 passed) - examples/ problems/ notebooks/ figures/ tables/ 骨架 - pyproject.toml(核心依赖 4 个 + 6 组 extras)、environment.yml - scripts/:setup-venv.ps1(ASCII + LF)、setup-conda.ps1、doctor.ps1 - 环境:.venv(system-site-packages)与 conda 环境 mcm-toolkit 均已建好, 数值栈版本与 base 完全对齐(numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6,MKL 后端) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fc5aeaf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +# 统一编辑器行为。多人(三人都用同一仓库)时能省掉大量格式争吵。 +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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..35198a8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,51 @@ +# 行尾策略:仓库内统一 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b2ea56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# ---- 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%/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7c10771 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# 更新日志 + +格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), +版本号参考 [语义化版本](https://semver.org/lang/zh-CN/)。 + +## [Unreleased] + +### Added +- 仓库骨架:`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 + +### Notes +- 所有函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。 + 这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。 +- 已知缺口:`SALib`(Sobol/Morris)、`pymoo`、`pyright` 未安装(本机离线)。 + +## [0.0.1] - 2026-10-01 + +### Added +- 项目初始化,包结构落盘(实现为空)。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..15091cd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 孤竹居士 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +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 following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b31edb --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# 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(clone base,离线) + +# 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) + +**接口已定,实现未填。** 所有函数体是 `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)。 diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..634d104 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,102 @@ +# STATUS — 当前进度 + +> 更新日期:2026-10-01 + +## 本次任务 + +审查并精简 ChatGPT 的建库建议 → 生成骨架文件 → 配置 conda 与 venv 环境。 + +**结论:全部完成。** 两套环境均已建成并通过验证。 + +## 任务清单 + +### ✅ 已完成 + +**A. 建议审查与精简** +- [x] 通读 `chatGPT_Suggestion.md`(33 节,约 2.4 万字),输出 `docs/01-suggestion-review.md` + - 总裁决:**方向对,量级错** + - 14 个包 → **7 个**;约 250 个函数 → **约 40 个**;5 个 Result 类 → **1 个** + - 单函数文档要求 10 项 → **2 档**(只有约 15 个招牌算法写完整卡片) + - 逐项给出"砍什么 / 为什么 / 替代什么",并补上原文缺失的六件事 + (论文产出链路、指标方向与量纲、时间预算、可复现性、测试测什么、离线约束) + +**B. 骨架文件生成**(78 个文件,约 6500 行) +- [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` `.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 3.13.9 + `--system-site-packages`) + + `pip install -e . --no-deps --no-build-isolation`; + 可直接导入 numpy 2.3.5 / scipy 1.16.3 / pandas 2.3.3 / matplotlib 3.10.6 / + sklearn 1.7.2 / statsmodels 0.14.5 / networkx 3.5 / sympy 1.14.0 +- [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` 实测会走完整下载安装,不再是"离线快捷方式") + +### 验证结果 + +| 检查 | venv | conda `mcm-toolkit` | +|---|---|---| +| `pytest` | 20 passed, 49 skipped | 20 passed, 49 skipped | +| `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 | +| `ruff check` | All checks passed | — | +| `scripts/doctor.ps1` | 跑通 | — | +| SALib / pymoo / pyright | 缺失 | 已装 | + +- skip 的都是等实现的算法行为测试;通过的 20 条是 `tests/test_api_surface.py` 的公开 API 契约测试 +- 三个 `.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。 +- [ ] 所有函数实现尚未开始(按 L0 → L1 顺序,见 `docs/03-roadmap.md`) +- [ ] Gitea 远程尚未配置(本地仓库已初始化并完成首次提交,推送步骤见 `docs/05-git-workflow.md`) +- [ ] 本次执行环境的 `exec_command` 在沙箱模式下**完全不可用** + (`helper_unknown_error: setup refresh had errors`), + 只有提权(`require_escalated`)模式可用。沙箱模式只允许写 + `D:\MCM&ICM_Toolkit`,因此前面一轮只能用 Node 子进程作为执行通道。 + +## 决策记录 + +| # | 决策 | 理由 | +|---|---|---| +| 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 | + +## 下一步 + +1. 按 `docs/03-roadmap.md` 的 **L0(12 个函数)** 顺序填实现,先写测试再写代码。 +2. 每写完一个函数,在 `examples/` 里加一行最小用法,并更新本文件。 +3. 在 Gitea 建空仓库 → `git remote add origin ...` → `git push -u origin main` + (命令见 `docs/05-git-workflow.md`)。 +4. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。 diff --git a/chatGPT_Suggestion.md b/chatGPT_Suggestion.md new file mode 100644 index 0000000..99dcbfa --- /dev/null +++ b/chatGPT_Suggestion.md @@ -0,0 +1,1660 @@ +可以,而且结合你现在的定位,我建议把它直接做成一个真正能长期积累的项目,而不是“一堆比赛代码的文件夹”。 + +我查了 GitHub 上目前比较相关的项目后,发现有几类东西特别值得借鉴: + +* **MCM-Resource**:最值得参考它的“按建模问题分类”的方式。它把优化、评价、预测、分类、微分方程、统计、模拟、辅助分析分开,同时还配了测试、类型检查、CI,这个思路非常适合你的工具箱。([GitHub][1]) +* **MathModelHub / CQULeaf 的 MCM-ICM 资源库**:值得参考“代码 + 历年题 + 数据分析 + 模板 + 比赛工作区”的整体组织方式。([GitHub][2]) +* **FelixYuan-YF/MCM-ICM**:适合拿来观察哪些传统模型在中文数学建模圈里被频繁整理成 Python 模块。([GitHub][3]) +* **SALib**:你尤其应该学习它的敏感性分析 API,包括 Sobol、Morris 等方法。([GitHub][4]) +* **NetworkX**:适合参考 ICM D 的网络算法 API 设计,比如最短路径、遍历、中心性等。([GitHub][5]) +* **statsmodels**:适合参考统计建模、回归、时间序列和检验的组织方法。([GitHub][6]) +* **pymoo**:适合参考优化问题的统一接口、结果对象、多目标优化和收敛分析。([GitHub][7]) +* **SymPy**:适合参考“解析数学 + 数值数学”如何统一,尤其是方程、矩阵、ODE 等求解。([GitHub][8]) + +我不建议你把这些库重新造一遍。**你的 mcm_toolkit 应该做“竞赛层封装 + 高频算法自己的简化实现 + 统一结果接口”,而不是替代 SciPy/sklearn/NetworkX。** + +--- + +# 一、我建议你的第一版直接采用这个结构 + +```text +mcm-toolkit/ +│ +├── pyproject.toml +├── README.md +├── LICENSE +├── CHANGELOG.md +│ +├── src/ +│ └── mcm_toolkit/ +│ │ +│ ├── __init__.py +│ ├── _version.py +│ │ +│ ├── core/ +│ │ ├── __init__.py +│ │ ├── types.py +│ │ ├── result.py +│ │ ├── validation.py +│ │ └── random.py +│ │ +│ ├── data/ +│ │ ├── __init__.py +│ │ ├── io.py +│ │ ├── clean.py +│ │ ├── transform.py +│ │ └── sampling.py +│ │ +│ ├── statistics/ +│ │ ├── __init__.py +│ │ ├── descriptive.py +│ │ ├── correlation.py +│ │ ├── inference.py +│ │ ├── distribution.py +│ │ └── bootstrap.py +│ │ +│ ├── regression/ +│ │ ├── __init__.py +│ │ ├── linear.py +│ │ ├── nonlinear.py +│ │ ├── polynomial.py +│ │ └── timeseries.py +│ │ +│ ├── ml/ +│ │ ├── __init__.py +│ │ ├── preprocessing.py +│ │ ├── regression.py +│ │ ├── classification.py +│ │ ├── clustering.py +│ │ ├── dimensionality.py +│ │ └── validation.py +│ │ +│ ├── optimization/ +│ │ ├── __init__.py +│ │ ├── continuous.py +│ │ ├── linear.py +│ │ ├── integer.py +│ │ ├── dynamic.py +│ │ ├── heuristic.py +│ │ └── multiobjective.py +│ │ +│ ├── simulation/ +│ │ ├── __init__.py +│ │ ├── monte_carlo.py +│ │ ├── stochastic.py +│ │ ├── markov.py +│ │ ├── random_walk.py +│ │ └── queueing.py +│ │ +│ ├── dynamics/ +│ │ ├── __init__.py +│ │ ├── ode.py +│ │ ├── difference.py +│ │ ├── equilibrium.py +│ │ └── stability.py +│ │ +│ ├── network/ +│ │ ├── __init__.py +│ │ ├── graph.py +│ │ ├── path.py +│ │ ├── centrality.py +│ │ ├── flow.py +│ │ └── community.py +│ │ +│ ├── decision/ +│ │ ├── __init__.py +│ │ ├── normalize.py +│ │ ├── weighting.py +│ │ ├── ahp.py +│ │ ├── topsis.py +│ │ ├── entropy.py +│ │ ├── grey.py +│ │ └── dea.py +│ │ +│ ├── sensitivity/ +│ │ ├── __init__.py +│ │ ├── local.py +│ │ ├── global_.py +│ │ ├── robustness.py +│ │ └── scenario.py +│ │ +│ ├── visualization/ +│ │ ├── __init__.py +│ │ ├── basic.py +│ │ ├── statistical.py +│ │ ├── model.py +│ │ ├── sensitivity.py +│ │ └── network.py +│ │ +│ └── utils/ +│ ├── __init__.py +│ ├── timing.py +│ ├── metrics.py +│ └── export.py +│ +├── tests/ +│ ├── test_statistics.py +│ ├── test_regression.py +│ ├── test_optimization.py +│ ├── test_simulation.py +│ ├── test_network.py +│ └── test_sensitivity.py +│ +├── examples/ +│ ├── monte_carlo.py +│ ├── regression.py +│ ├── ode.py +│ ├── optimization.py +│ └── network.py +│ +└── docs/ + ├── algorithms/ + ├── tutorials/ + └── api/ +``` + +这个结构其实就是把 GitHub 上 MCM-Resource 的“模型知识分类”、成熟科学库的模块化设计和你自己的“比赛快速调用”结合起来。([GitHub][1]) + +--- + +# 二、核心层 `core` + +这些不是数学模型,但必须有。 + +### `core/random.py` + +```python +set_seed(seed: int) -> None +get_rng(seed: int | None = None) -> np.random.Generator +``` + +### `core/validation.py` + +```python +check_array(x, ndim=None, finite=True) +check_same_length(*arrays) +check_probability(p) +check_bounds(x, lower, upper) +check_square_matrix(A) +``` + +### `core/types.py` + +建议定义: + +```python +ArrayLike +Scalar +Objective +Constraint +``` + +### `core/result.py` + +建议做统一结果对象: + +```python +FitResult +OptimizationResult +SimulationResult +SensitivityResult +ForecastResult +``` + +例如: + +```python +result.params +result.metrics +result.predictions +result.success +result.message +``` + +这个设计非常重要。 + +你以后不要让每个函数返回完全不同的东西。 + +--- + +# 三、数据处理 `data` + +这是你比赛最经常碰到的一层。 + +## `data/io.py` + +```python +load_csv(path) +load_excel(path, sheet_name=0) +save_csv(data, path) +save_excel(data, path) +``` + +## `data/clean.py` + +```python +drop_missing(data, axis=0, threshold=None) + +impute_missing( + data, + method="mean", +) + +remove_duplicates(data) + +detect_outliers( + data, + method="iqr", +) + +winsorize( + data, + lower=0.01, + upper=0.99, +) +``` + +## `data/transform.py` + +```python +normalize(x, axis=0) +standardize(x, axis=0) + +minmax_scale( + x, + feature_range=(0, 1), +) + +log_transform(x) +boxcox_transform(x) +rank_transform(x) + +one_hot_encode(data, columns) +``` + +## `data/sampling.py` + +```python +train_test_split(...) +kfold_split(...) +stratified_split(...) +bootstrap_sample(...) +sample_without_replacement(...) +sample_with_replacement(...) +``` + +--- + +# 四、统计学 `statistics` + +这应该是你的 **S 级模块**。 + +## 描述统计 + +```python +describe(x) +weighted_mean(x, weights) +weighted_variance(x, weights) +variance(x) +std(x) +coefficient_of_variation(x) +quantile(x, q) +iqr(x) +zscore(x) +``` + +## 相关分析 + +```python +covariance(x, y) +correlation(x, y, method="pearson") +spearman_correlation(x, y) +kendall_correlation(x, y) +correlation_matrix(X) +partial_correlation(X, y) +``` + +## 离群值 + +```python +iqr_outliers(x, k=1.5) +zscore_outliers(x, threshold=3.0) +modified_zscore_outliers(x) +``` + +## 统计推断 + +```python +confidence_interval( + x, + confidence=0.95, +) + +mean_test(...) +proportion_test(...) +chi_square_test(...) +t_test(...) +anova(...) +``` + +## 分布 + +```python +fit_distribution(x, dist="normal") +distribution_pdf(...) +distribution_cdf(...) +distribution_ppf(...) +sample_distribution(...) +goodness_of_fit(...) +``` + +--- + +# 五、Bootstrap + +这个建议你**自己认真实现一遍**。 + +```python +bootstrap_sample( + x, + n_resamples=10000, + rng=None, +) + +bootstrap_statistic( + x, + statistic, + n_resamples=10000, +) + +bootstrap_ci( + x, + statistic=np.mean, + confidence=0.95, + n_resamples=10000, + method="percentile", +) +``` + +以后你甚至可以: + +```python +result = bootstrap_ci( + data, + statistic=np.median, +) +``` + +--- + +# 六、回归 `regression` + +## 线性 + +```python +fit_linear(X, y) + +fit_weighted_linear(X, y, weights) + +fit_ridge(X, y, alpha) + +predict_linear(model, X) + +linear_coefficients(model) +``` + +## 多项式 + +```python +fit_polynomial(X, y, degree=2) +predict_polynomial(model, X) +``` + +## 非线性 + +```python +fit_nonlinear( + func, + X, + y, + p0, + bounds=None, +) + +curve_fit( + func, + x, + y, + p0=None, +) +``` + +## 模型评估 + +```python +rmse(y_true, y_pred) +mae(y_true, y_pred) +mape(y_true, y_pred) +smape(y_true, y_pred) +r2_score(y_true, y_pred) +adjusted_r2(...) +``` + +## 残差 + +```python +residuals(y_true, y_pred) +residual_statistics(...) +residual_autocorrelation(...) +``` + +--- + +# 七、时间序列 `regression/timeseries.py` + +MCM C 特别值得准备。 + +```python +lag_features(series, lags) + +moving_average(series, window) + +weighted_moving_average(series, weights) + +exponential_smoothing( + series, + alpha, +) + +holt_trend( + series, + alpha, + beta, +) + +seasonal_decompose(series, period) + +fit_arima( + series, + order=(p, d, q), +) + +forecast(model, steps) + +forecast_interval(model, alpha=0.05) +``` + +statsmodels 本身已经有大量成熟的 AR/ARIMA/状态空间/指数平滑能力,所以这里建议做竞赛友好的薄封装,而不要自己重新实现整套时间序列框架。([GitHub][6]) + +--- + +# 八、机器学习 `ml` + +这里要控制规模。 + +## 预处理 + +```python +scale_features(X, method="standard") +select_features(X, y, method="correlation") +``` + +## 回归 + +```python +fit_linear_regression(X, y) +fit_ridge_regression(X, y) +fit_random_forest_regressor(X, y) +fit_gradient_boosting(X, y) +``` + +## 分类 + +```python +fit_logistic(X, y) +fit_svm(X, y) +fit_random_forest_classifier(X, y) +``` + +## 聚类 + +```python +kmeans(X, n_clusters) +dbscan(X, eps, min_samples) +hierarchical_clustering(X, n_clusters) +``` + +## 降维 + +```python +pca(X, n_components) +``` + +## 验证 + +```python +cross_validate(model, X, y, cv=5) +grid_search(model, param_grid, X, y) +``` + +## 指标 + +```python +classification_metrics(...) +clustering_metrics(...) +regression_metrics(...) +``` + +scikit-learn 的一个很值得你模仿的地方,就是把“模型、预测、验证、指标”组织成一套一致的接口,而不是每个算法自己搞一套参数和返回值。([GitHub][9]) + +--- + +# 九、优化 `optimization` + +这是你们队伍里数学手和你会大量合作的地方。 + +## 连续优化 + +```python +minimize( + objective, + x0, + bounds=None, + constraints=None, + method="auto", +) + +maximize( + objective, + x0, + bounds=None, + constraints=None, +) +``` + +底层直接调用 SciPy。 + +--- + +# 十、线性规划 + +```python +linear_program( + c, + A_ub=None, + b_ub=None, + A_eq=None, + b_eq=None, + bounds=None, +) +``` + +返回: + +```python +OptimizationResult( + x=..., + objective=..., + success=True, +) +``` + +--- + +# 十一、整数规划 + +```python +integer_program( + c, + integrality, + A_ub=None, + b_ub=None, + A_eq=None, + b_eq=None, + bounds=None, +) +``` + +--- + +# 十二、动态规划 + +这个建议你**一定自己做**。 + +```python +dynamic_programming( + states, + actions, + transition, + reward, + horizon, +) + +value_iteration( + states, + actions, + transition, + reward, + gamma=0.95, +) + +policy_iteration(...) +``` + +再做几个非常通用的竞赛版本: + +```python +knapsack_dp(...) +shortest_path_dp(...) +sequence_dp(...) +resource_allocation_dp(...) +``` + +--- + +# 十三、启发式算法 + +这个模块非常适合你使用 AI 辅助开发。 + +```python +greedy_search(...) +local_search(...) +simulated_annealing(...) +genetic_algorithm(...) +particle_swarm(...) +``` + +但这里我建议: + +**第一版只自己实现 `greedy_search`、`local_search`、`simulated_annealing`、简化版 `genetic_algorithm`。** + +PSO/NSGA-II 等复杂算法直接使用成熟库。 + +--- + +# 十四、多目标优化 + +这个模块直接借鉴 pymoo 的思想。 + +```python +weighted_sum( + objectives, + weights, +) + +pareto_front( + solutions, + objectives, +) + +pareto_filter(points) + +crowding_distance(points) + +hypervolume(...) +``` + +如果题目真的需要 NSGA-II: + +```python +from pymoo... +``` + +直接调用。 + +pymoo 本身已经把单目标、多目标、约束、算法、可视化和收敛分析组织成比较完整的优化框架,因此你的工具箱没必要重复造轮子。([GitHub][7]) + +--- + +# 十五、Monte Carlo `simulation` + +**这是你的核心模块之一。** + +```python +monte_carlo( + sampler, + simulator, + n=10000, + rng=None, +) + +monte_carlo_expectation( + func, + sampler, + n=10000, +) + +monte_carlo_probability( + event, + sampler, + n=10000, +) + +monte_carlo_integral( + func, + bounds, + n=10000, +) + +monte_carlo_ci( + values, + confidence=0.95, +) +``` + +还可以: + +```python +parameter_sweep(...) +random_parameter_sweep(...) +``` + +--- + +# 十六、Markov + +```python +markov_simulate( + transition_matrix, + initial_state, + steps, +) + +markov_steady_state( + transition_matrix, +) + +markov_absorption( + transition_matrix, + absorbing_states, +) + +markov_expected_hitting_time(...) +``` + +--- + +# 十七、随机过程 + +```python +random_walk( + n_steps, + p=0.5, +) + +brownian_motion( + n_steps, + dt, +) + +geometric_brownian_motion(...) +``` + +这些在金融、扩散、人口、风险等题中都可能派上用场。 + +--- + +# 十八、排队论 + +```python +mm1_metrics( + arrival_rate, + service_rate, +) + +mmc_metrics( + arrival_rate, + service_rate, + servers, +) + +simulate_queue( + arrival_process, + service_process, + servers=1, + horizon=1000, +) +``` + +--- + +# 十九、微分方程 `dynamics` + +你虽然不一定主攻 MCM A,但这个模块还是值得建立。 + +## ODE + +```python +solve_ode( + derivative, + t_span, + y0, + t_eval=None, +) + +solve_system(...) +``` + +## 差分方程 + +```python +solve_difference_equation( + update, + x0, + steps, +) +``` + +## 平衡点 + +```python +find_equilibrium( + f, + x0, +) + +equilibrium_points(...) +``` + +## 稳定性 + +```python +jacobian(f, x0) + +eigenvalues(A) + +local_stability( + jacobian_matrix, +) + +stability_classification(...) +``` + +SymPy 对方程、矩阵和 ODE 的解析/数值求解本身已经非常成熟,因此这个模块建议做“竞赛接口”,而不是再造 CAS。([GitHub][8]) + +--- + +# 二十、网络 `network` + +这部分对 ICM D 很重要。 + +## 图构建 + +```python +build_graph( + edges, + directed=False, + weighted=True, +) + +adjacency_matrix(G) + +incidence_matrix(G) +``` + +## 路径 + +```python +shortest_path(G, source, target) + +shortest_path_length(G, source, target) + +all_pairs_shortest_path(G) + +minimum_spanning_tree(G) +``` + +## 中心性 + +```python +degree_centrality(G) + +betweenness_centrality(G) + +closeness_centrality(G) + +eigenvector_centrality(G) + +pagerank(G) +``` + +## 流 + +```python +max_flow(G, source, sink) + +min_cut(G, source, sink) +``` + +## 网络结构 + +```python +connected_components(G) + +density(G) + +clustering_coefficient(G) + +average_path_length(G) +``` + +## 社区 + +```python +community_detection(G) + +modularity(G, communities) +``` + +这些正好对应 NetworkX 已提供的很多核心图算法类型。([GitHub][5]) + +--- + +# 二十一、评价决策 `decision` + +这个模块我建议保留,但优先级低于统计/优化/模拟。 + +## 标准化 + +```python +benefit_normalize(X) +cost_normalize(X) +vector_normalize(X) +zscore_normalize(X) +``` + +## 权重 + +```python +equal_weights(n) + +entropy_weights(X) + +ahp_weights(pairwise_matrix) + +critic_weights(X) +``` + +## 综合评价 + +```python +topsis( + X, + weights=None, + benefit_columns=None, +) + +grey_relational_analysis(X, reference) + +fuzzy_comprehensive_evaluation(...) +``` + +## DEA + +```python +dea_efficiency(...) +``` + +--- + +# 二十二、AHP + +如果做就做成比较简单的: + +```python +ahp_weights( + matrix, +) + +ahp_consistency_ratio( + matrix, +) +``` + +--- + +# 二十三、灰色模型 + +虽然它更常见于中文建模赛事,但做成一个很小的模块没问题。 + +```python +gm11_fit( + series, +) + +gm11_predict( + model, + steps, +) + +gm11_fit_predict( + series, + steps, +) +``` + +--- + +# 二十四、敏感性分析 + +这个模块我建议直接做成你的招牌模块。 + +## 局部敏感性 + +```python +finite_difference_sensitivity( + model, + params, + step=1e-4, +) + +local_sensitivity( + model, + params, +) + +elasticity( + model, + params, +) +``` + +## OAT + +```python +one_at_a_time( + model, + parameter, + values, +) +``` + +## 参数扫描 + +```python +parameter_sweep( + model, + parameter_ranges, +) + +grid_sweep(...) +``` + +## 鲁棒性 + +```python +robustness_test( + model, + scenarios, +) + +perturbation_test( + model, + parameters, + perturbation=0.1, +) +``` + +## 全局敏感性 + +```python +sobol_sample(...) +sobol_analyze(...) + +morris_sample(...) +morris_analyze(...) +``` + +这部分非常建议直接参考 SALib 的思路,而不是自己发明接口。SALib 已经实现了 Sobol、Morris、FAST 等经典方法。([GitHub][4]) + +--- + +# 二十五、可视化 `visualization` + +你最后一定会非常高频使用。 + +```python +plot_line(...) +plot_multi_line(...) + +plot_scatter(...) +plot_regression(...) + +plot_histogram(...) +plot_boxplot(...) + +plot_heatmap(...) +plot_correlation_matrix(...) + +plot_residuals(...) +plot_prediction_interval(...) + +plot_convergence(...) +plot_parameter_sensitivity(...) + +plot_monte_carlo_distribution(...) + +plot_network(...) +``` + +还有一个非常有用的: + +```python +save_figure( + fig, + path, + dpi=300, + formats=("png", "pdf"), +) +``` + +--- + +# 二十六、模型验证 + +我非常建议单独建立: + +```text +analysis/ +``` + +或者把它放到 `utils/metrics.py`。 + +最基本: + +```python +rmse(...) +mae(...) +mape(...) +smape(...) +r2_score(...) +``` + +然后: + +```python +cross_validation(...) +residual_analysis(...) +error_analysis(...) +prediction_interval(...) +confidence_interval(...) +``` + +再做: + +```python +compare_models( + models, + X, + y, + metrics=("rmse", "mae", "r2"), +) +``` + +这个函数比赛时特别好用。 + +--- + +# 二十七、最终我建议你再加入一个 `model_compare` + +比如: + +```python +result = compare_models( + { + "linear": linear_model, + "polynomial": polynomial_model, + "random_forest": rf_model, + }, + X, + y, +) +``` + +结果自动返回: + +```text + RMSE MAE R² +linear ... +poly ... +randomforest ... +``` + +这样你们数学手提出: + +> “我们要不要用复杂模型?” + +你直接跑一次。 + +这比争论半小时有效。 + +--- + +# 二十八、最关键的:不要所有函数都自己写 + +我建议你把整个项目里的函数分成三个等级。 + +## 🟢 A:强烈建议自己实现 + +这些最适合你通过 AI 辅助学习: + +```text +bootstrap +Monte Carlo +random walk +Markov +dynamic programming +greedy +local search +simulated annealing +basic genetic algorithm +finite difference +sensitivity analysis +parameter sweep +normalization +TOPSIS +entropy weighting +AHP consistency +GM(1,1) +模型评价 +模型比较 +``` + +这些实现之后,你会真正理解算法。 + +--- + +## 🟡 B:自己封装,底层调用成熟库 + +例如: + +```text +linear regression +nonlinear optimization +ODE +ARIMA +SVM +Random Forest +KMeans +PCA +linear programming +integer programming +graph algorithms +``` + +即: + +```python +def linear_program(...): + return scipy.optimize.linprog(...) +``` + +你学习的是: + +> **怎样把成熟工具变成符合 MCM 工作流的接口。** + +--- + +## 🔴 C:不要自己造轮子 + +例如: + +```text +完整 Transformer +完整 PyTorch training framework +完整 NSGA-II +完整 NetworkX +完整 statsmodels +完整 SciPy optimizer +完整 symbolic algebra system +``` + +直接调用成熟库。 + +--- + +# 二十九、你的 `__init__.py` 应该怎么设计 + +我建议不是: + +```python +from .statistics import * +from .optimization import * +from .simulation import * +... +``` + +全部塞进顶层。 + +而是: + +```python +from . import data +from . import statistics +from . import regression +from . import optimization +from . import simulation +from . import dynamics +from . import network +from . import sensitivity +from . import visualization +``` + +然后再提供少量最高频快捷函数: + +```python +from .simulation import monte_carlo +from .statistics import bootstrap_ci +from .optimization import minimize +from .sensitivity import parameter_sweep +``` + +于是比赛的时候你可以: + +```python +import mcm_toolkit as mcm + +mcm.monte_carlo(...) +mcm.bootstrap_ci(...) +``` + +也可以: + +```python +from mcm_toolkit import statistics +statistics.correlation_matrix(...) +``` + +这样既方便,又不会污染 namespace。 + +--- + +# 三十、`__all__` 也可以保留 + +例如: + +```python +__all__ = [ + "monte_carlo", + "bootstrap_ci", + "minimize", + "solve_ode", + "parameter_sweep", + "topsis", + "pareto_front", +] +``` + +注意: + +**`__all__` 是“公开 API 清单”,不是“把所有东西导出来的按钮”。** + +这一点你从第一版就养成习惯,后面会舒服很多。 + +--- + +# 三十一、依赖不要一上来装几十个 + +我建议: + +```toml +[project] +dependencies = [ + "numpy", + "scipy", + "pandas", + "matplotlib", +] +``` + +然后 extras: + +```toml +[project.optional-dependencies] + +ml = [ + "scikit-learn", + "statsmodels", +] + +network = [ + "networkx", +] + +sensitivity = [ + "SALib", +] + +symbolic = [ + "sympy", +] + +advanced-optimization = [ + "pymoo", +] + +excel = [ + "openpyxl", +] + +dev = [ + "pytest", + "ruff", + "pyright", +] +``` + +这样非常符合你之前强调的**轻量化**思路,而且结构上也参考了 MCM-Resource 对 core / algorithm / optional dependencies 的分层。([GitHub][1]) + +--- + +# 三十二、我甚至建议你的项目不要只有 `mcm_toolkit` + +最终 GitHub 仓库可以: + +```text +mcm-toolkit/ +│ +├── src/ +│ └── mcm_toolkit/ +│ +├── examples/ +├── tests/ +├── docs/ +│ +├── problems/ +│ ├── 2025_C/ +│ ├── 2025_D/ +│ └── ... +│ +└── notebooks/ +``` + +这样你每做一道题,就沉淀一次: + +```text +题目 + ↓ +发现某个模型 + ↓ +实现通用函数 + ↓ +写 test + ↓ +加入 toolkit + ↓ +在 examples 写一个例子 +``` + +**一年之后,你真正拥有的不是 20 道题的代码,而是一套自己的建模计算框架。** + +这也是我觉得现在 GitHub 上这些 MCM 资源项目里,最值得你吸收的思想:把“历年题解”进一步抽象成“模型 + 算法 + 可复用代码 + 测试 + 文档”。([GitHub][1]) + +--- + +# 三十三、我会把你的 v0.1 最终压缩成这套核心 API + +如果只看**第一版必须完成的 60 个左右函数**,我建议就是: + +```text +数据 +├── load_csv +├── load_excel +├── clean_missing +├── impute_missing +├── detect_outliers +├── normalize +├── standardize +└── train_test_split + +统计 +├── describe +├── correlation +├── correlation_matrix +├── zscore +├── confidence_interval +├── t_test +├── anova +├── fit_distribution +├── bootstrap_sample +└── bootstrap_ci + +回归 +├── fit_linear +├── fit_polynomial +├── fit_nonlinear +├── predict +├── rmse +├── mae +├── mape +└── r2_score + +优化 +├── minimize +├── linear_program +├── integer_program +├── dynamic_programming +├── greedy_search +├── local_search +├── simulated_annealing +├── genetic_algorithm +├── pareto_front +└── weighted_sum + +模拟 +├── monte_carlo +├── monte_carlo_probability +├── monte_carlo_integral +├── random_walk +├── markov_simulate +├── markov_steady_state +├── mm1_metrics +└── parameter_sweep + +动力学 +├── solve_ode +├── solve_difference_equation +├── find_equilibrium +├── jacobian +└── local_stability + +网络 +├── build_graph +├── shortest_path +├── minimum_spanning_tree +├── degree_centrality +├── betweenness_centrality +├── closeness_centrality +├── pagerank +└── max_flow + +评价 +├── entropy_weights +├── ahp_weights +├── topsis +├── grey_relational_analysis +└── dea_efficiency + +敏感性 +├── local_sensitivity +├── finite_difference_sensitivity +├── one_at_a_time +├── parameter_sweep +├── robustness_test +├── sobol_analyze +└── morris_analyze + +可视化 +├── plot_line +├── plot_scatter +├── plot_histogram +├── plot_boxplot +├── plot_heatmap +├── plot_correlation_matrix +├── plot_residuals +├── plot_convergence +└── plot_sensitivity +``` + +这套东西已经足够构成一个**相当完整的 MCM/ICM 编程手工具箱**。 + +--- + +## 最后一个我认为非常适合你的设计 + +你之前说的: + +> “让 AI 帮我写模块,然后我读 AI 的代码继续学习。” + +我建议正式把它写进开发规范。 + +每一个函数都强制要求: + +```text +1. 数学定义 +2. 适用场景 +3. 参数说明 +4. Python 实现 +5. 最小示例 +6. 单元测试 +7. 时间复杂度 +8. 数值稳定性说明 +9. 常见错误 +10. MCM/ICM 使用案例 +``` + +例如你让 Codex/Claude Code 帮你写 `monte_carlo()`,不要只得到: + +```python +def monte_carlo(...): + ... +``` + +而是得到: + +```text +monte_carlo +├── 数学原理 +├── 实现 +├── test +├── example +└── MCM案例 +``` + +这样你每开发一个函数,就相当于**同时完成一次算法学习 + 一次 Python 训练 + 一次竞赛知识积累**。 + +而且从你现在的情况看,我会优先让你开始写: + +**`statistics → regression → simulation → optimization → sensitivity → network`** + +这六块最能形成你作为“编程手”的核心能力。对于你们这种**数学竞赛建模手 + 英语写作手 + 计算/AI 编程手**的三人组合,这个方向比继续单独刷 PyTorch 更有价值。 + +[1]: https://github.com/dreamnight16/MCM-Resource "GitHub - dreamnight16/MCM-Resource: 数学建模资源库 (MCM Resource) — 8大类模型 × 27个算法 × 4个赛事指南 · GitHub" +[2]: https://github.com/Juccy-12/MathModelHub "GitHub - Juccy-12/MathModelHub: 美赛/ICM综合准备资源库。包含可复用的数学建模Python代码、LaTeX/Word模板、数据分析笔记本和历年赛题组织。旨在优化竞赛流程,促进可复现研究。A comprehensive toolkit for MCM/ICM preparation. Features reusable Python code for mathematical modeling, LaTeX/Word templates, data analysis notebooks, and organized past problems. Aimed at streamlining the contest workflow and fostering reproducible research. · GitHub" +[3]: https://github.com/FelixYuan-YF/MCM-ICM "GitHub - FelixYuan-YF/MCM-ICM: 美赛常用模型的Python实现,以及历年Outstanding获奖文章 · GitHub" +[4]: https://github.com/salib/salib?utm_source=chatgpt.com "GitHub - SALib/SALib: Sensitivity Analysis Library in Python. Contains Sobol, Morris, FAST, and other methods. · GitHub" +[5]: https://github.com/networkx/networkx/blob/main/doc/reference/introduction.md?utm_source=chatgpt.com "networkx/doc/reference/introduction.md at main · networkx/networkx · GitHub" +[6]: https://github.com/statsmodels/statsmodels?utm_source=chatgpt.com "GitHub - statsmodels/statsmodels: Statsmodels: statistical modeling and econometrics in Python · GitHub" +[7]: https://github.com/anyoptimization/pymoo/blob/main/docs/source/index.rst?utm_source=chatgpt.com "pymoo/docs/source/index.rst at main · anyoptimization/pymoo · GitHub" +[8]: https://github.com/sympy/sympy/blob/master/doc/src/guides/solving/index.md?utm_source=chatgpt.com "sympy/doc/src/guides/solving/index.md at master · sympy/sympy · GitHub" +[9]: https://github.com/scikit-learn/scikit-learn/blob/main/doc/modules/model_evaluation.rst?utm_source=chatgpt.com "scikit-learn/doc/modules/model_evaluation.rst at main · scikit-learn/scikit-learn · GitHub" diff --git a/docs/01-suggestion-review.md b/docs/01-suggestion-review.md new file mode 100644 index 0000000..c5c6a46 --- /dev/null +++ b/docs/01-suggestion-review.md @@ -0,0 +1,207 @@ +# 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 个包建齐"相反。 diff --git a/docs/02-architecture.md b/docs/02-architecture.md new file mode 100644 index 0000000..6af6147 --- /dev/null +++ b/docs/02-architecture.md @@ -0,0 +1,178 @@ +# 架构设计 + +> 上游依据:`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 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` 裸抛。 + +--- + +## 五、测试策略 + +- 目录 `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`。 +- 每条断言都要能说清"错了说明什么"。 diff --git a/docs/03-roadmap.md b/docs/03-roadmap.md new file mode 100644 index 0000000..6e7f93c --- /dev/null +++ b/docs/03-roadmap.md @@ -0,0 +1,112 @@ +# 路线图与开发纪律 + +> 依据:`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 | + +### 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` | +| `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/` 里写一条会失败的断言(测已知解析解,不测实现细节)。 +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 | 仓库自然长大 | + +**核心原则:先跑通一条完整链路,再补宽度。** diff --git a/docs/04-environment.md b/docs/04-environment.md new file mode 100644 index 0000000..a1f0e59 --- /dev/null +++ b/docs/04-environment.md @@ -0,0 +1,238 @@ +# 环境配置 + +> 记录本机(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,写代码时注意别依赖两套环境都有的行为。 + +--- + +## 五、两套环境的实际版本(已对齐) + +**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 升级记录 + +[FENCE]powershell +conda update -n base --all -y # 365 个包更新,下载 638 MB,耗时 1033 秒 +conda install -n mcm-toolkit numpy=2.4.6 -y # 把项目环境对齐到 base 的 numpy +[FENCE] + +* `conda` 本身从 25.11.1 升到 **26.7.3**。 +* 升级前 `conda list -n base --revisions` 里已留有 revision 记录,可回滚: + `conda install -n base --revision `。 +* **踩过的坑**: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" +``` diff --git a/docs/05-git-workflow.md b/docs/05-git-workflow.md new file mode 100644 index 0000000..3932731 --- /dev/null +++ b/docs/05-git-workflow.md @@ -0,0 +1,137 @@ +# Git 工作流与远程托管 + +> 本仓库目前是**本地仓库**,之后托管到你自己的 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 + +### 1. 在 Gitea 上建空仓库 + +不要勾选"初始化仓库"(不要生成 README / .gitignore / LICENSE), +否则首次 push 会冲突。 + +### 2. 加远程并推送 + +```powershell +cd D:\MCM&ICM_Toolkit + +# 换成你自己的 Gitea 地址 +git remote add origin https://git.你的域名/用户名/mcm-icm-toolkit.git + +# 如果 Gitea 用的是自签证书,先在浏览器里访问一次并信任,或: +# git config --global http.sslBackend schannel + +git branch -M main +git push -u origin main +``` + +### 3. 用 SSH(推荐,免密) + +```powershell +ssh-keygen -t ed25519 -C "guzhu@mcm-toolkit" +Get-Content "$env:USERPROFILE\.ssh\id_ed25519.pub" | Set-Clipboard +# 粘贴到 Gitea → 设置 → SSH/GPG 密钥 + +git remote set-url origin git@git.你的域名:用户名/mcm-icm-toolkit.git +git push -u origin main +``` + +### 4. 如果不想公开 + +Gitea 建仓库时选 **Private**。队友通过 +仓库设置 → 协作者 → 添加成员 授权。 + +--- + +## 四、给队友的说明 + +队友 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 +# conda 自己保留了 revision 历史 +conda list -n base --revisions +conda install -n base --revision + +# 代码回滚 +git log --oneline +git revert +``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2b72c7a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,20 @@ +# 文档索引 + +本目录是 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 档算法的「函数卡片」模板 | + +## 阅读顺序 + +1. 先读 `01-suggestion-review.md` —— 它决定后面所有事。 +2. 再读 `02-architecture.md` —— 它决定代码往哪写。 +3. 开工前读 `03-roadmap.md` 的对应条目 —— 它决定这次做什么、做到什么程度。 +4. 环境出问题看 `04-environment.md`。 + +> 项目级进度看根目录的 `STATUS.md`;本目录只放**不会频繁变**的内容。 diff --git a/docs/algorithms/README.md b/docs/algorithms/README.md new file mode 100644 index 0000000..784affd --- /dev/null +++ b/docs/algorithms/README.md @@ -0,0 +1,26 @@ +# 算法卡片 + +S 档(自己实现的招牌算法)的**函数卡片**放这里,模板见 +[`../dev/function-card-template.md`](../dev/function-card-template.md)。 + +计划要写的卡片(见 `../03-roadmap.md` §3.1,约 15 张): + +| 卡片 | 对应函数 | 状态 | +|---|---|---| +| `detect_outliers.md` | `data.detect_outliers` | ⬜ | +| `bootstrap_ci.md` | `stats.bootstrap_ci` | ⬜ | +| `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 档,应该降档删卡片。 diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..d6638b4 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,18 @@ +# 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 放进依赖**——它是可选的、和比赛无关的工具。 diff --git a/docs/dev/function-card-template.md b/docs/dev/function-card-template.md new file mode 100644 index 0000000..0cf4502 --- /dev/null +++ b/docs/dev/function-card-template.md @@ -0,0 +1,137 @@ +# 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 结果不同时要说明选了哪个及理由。 +``` diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md new file mode 100644 index 0000000..275cd5c --- /dev/null +++ b/docs/tutorials/README.md @@ -0,0 +1,18 @@ +# 教程 + +按"题型"组织的实战教程放这里,每篇对应一类赛题。 + +**目前为空是刻意的**:教程应该在**做过至少一道该类型的真题之后**再写, +否则写出来的都是"字典式罗列",没有踩坑经验,没有价值。 + +计划(做完对应真题后再写): + +| 教程 | 对应题型 | 触发条件 | +|---|---|---| +| `评价类.md` | 无量纲化 → 赋权 → TOPSIS/灰色 → 敏感性 | 做完一道评价类真题 | +| `预测类.md` | 描述 → 分布拟合 → ARIMA/GM(1,1) → 区间 → 误差分析 | 做完一道预测类真题 | +| `优化类.md` | 建模 → LP/IP → 启发式 → 多目标 → 敏感性 | 做完一道优化类真题 | +| `仿真类.md` | 参数分布 → Monte Carlo → 收敛性 → 鲁棒性 | 做完一道仿真类真题 | + +面向"怎么用这个工具箱"的快速上手看 [`../04-environment.md`](../04-environment.md) 和 +仓库根目录的 `README.md`;这里放的是**建模思路**。 diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..8767f85 --- /dev/null +++ b/environment.yml @@ -0,0 +1,53 @@ +# 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 . # 本项目(可编辑安装) diff --git a/examples/00_quickstart.py b/examples/00_quickstart.py new file mode 100644 index 0000000..a1b6c98 --- /dev/null +++ b/examples/00_quickstart.py @@ -0,0 +1,42 @@ +"""端到端最小流程:读数据 → 清洗 → 描述 → 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() diff --git a/examples/03_monte_carlo.py b/examples/03_monte_carlo.py new file mode 100644 index 0000000..94b909b --- /dev/null +++ b/examples/03_monte_carlo.py @@ -0,0 +1,48 @@ +"""蒙特卡洛:估计 π、积 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() diff --git a/examples/04_decision_topsis.py b/examples/04_decision_topsis.py new file mode 100644 index 0000000..4470116 --- /dev/null +++ b/examples/04_decision_topsis.py @@ -0,0 +1,45 @@ +"""评价类题目的标准流程:无量纲化 → 赋权 → 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() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..907bd3f --- /dev/null +++ b/examples/README.md @@ -0,0 +1,21 @@ +# 例子 + +每个模块一个能直接跑的最小例子。 + +> **当前状态**:这些脚本是**用法规格**,函数实现填好之前跑不通 +> (会抛 `NotImplementedError`)。实现顺序见 `docs/03-roadmap.md`。 + +| 文件 | 对应模块 | 覆盖的 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 +``` diff --git a/figures/.gitkeep b/figures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/notebooks/.gitkeep b/notebooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/problems/README.md b/problems/README.md new file mode 100644 index 0000000..5fc4500 --- /dev/null +++ b/problems/README.md @@ -0,0 +1,25 @@ +# 历年真题 + +每做一道题,就把"题目 → 思路 → 代码 → 结论"沉淀到这里, +并**反向补 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. 论文里被评审/队友质疑的地方是什么?→ 对应的函数要补文档或补鲁棒性检验。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..76442d2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[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" diff --git a/scripts/doctor.ps1 b/scripts/doctor.ps1 new file mode 100644 index 0000000..e93abdd --- /dev/null +++ b/scripts/doctor.ps1 @@ -0,0 +1,103 @@ +#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 diff --git a/scripts/setup-conda.ps1 b/scripts/setup-conda.ps1 new file mode 100644 index 0000000..45093df --- /dev/null +++ b/scripts/setup-conda.ps1 @@ -0,0 +1,136 @@ +#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" diff --git a/scripts/setup-venv.ps1 b/scripts/setup-venv.ps1 new file mode 100644 index 0000000..59d7517 --- /dev/null +++ b/scripts/setup-venv.ps1 @@ -0,0 +1,71 @@ +#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/3] 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' + +Write-Host '[2/3] 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 '[3/3] 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' diff --git a/src/mcm_toolkit/__init__.py b/src/mcm_toolkit/__init__.py new file mode 100644 index 0000000..63f06bf --- /dev/null +++ b/src/mcm_toolkit/__init__.py @@ -0,0 +1,54 @@ +"""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", +] diff --git a/src/mcm_toolkit/_version.py b/src/mcm_toolkit/_version.py new file mode 100644 index 0000000..dc2afa7 --- /dev/null +++ b/src/mcm_toolkit/_version.py @@ -0,0 +1,3 @@ +"""版本号唯一来源。""" + +__version__ = "0.0.1" diff --git a/src/mcm_toolkit/core/__init__.py b/src/mcm_toolkit/core/__init__.py new file mode 100644 index 0000000..94e81e9 --- /dev/null +++ b/src/mcm_toolkit/core/__init__.py @@ -0,0 +1,23 @@ +"""core —— 基础设施:结果对象、随机数、参数校验。 + +其他所有包都依赖本包;本包**不**依赖任何其他子包。 +""" + +from .random import get_rng, set_seed +from .result import Result +from .validate import ( + check_array, + check_probability, + check_same_length, + check_square_matrix, +) + +__all__ = [ + "Result", + "get_rng", + "set_seed", + "check_array", + "check_probability", + "check_same_length", + "check_square_matrix", +] diff --git a/src/mcm_toolkit/core/random.py b/src/mcm_toolkit/core/random.py new file mode 100644 index 0000000..7cfb5c3 --- /dev/null +++ b/src/mcm_toolkit/core/random.py @@ -0,0 +1,45 @@ +"""随机数约定。 + +规则(见 docs/02-architecture.md §4.2): + +* 所有含随机的公开函数签名都带 `random_state=None`,内部第一行取 rng。 +* 禁止使用 `np.random.*` 旧式全局接口,也禁止裸用标准库 `random`。 +* 论文附录必须写明 seed —— 提交要能复现。 +""" + +from __future__ import annotations + +import numpy as np + +__all__ = ["get_rng", "set_seed"] + + +def get_rng(random_state: int | None = None) -> np.random.Generator: + """取一个随机数生成器。 + + Parameters + ---------- + random_state: + `int` 时返回固定种子的独立生成器(可复现); + `None` 时返回基于全局种子的生成器; + 已经是 :class:`np.random.Generator` 时原样返回(便于函数间串联)。 + + Examples + -------- + >>> rng = get_rng(42) + >>> bool(rng.random() == get_rng(42).random()) + True + """ + # TODO(L0) + raise NotImplementedError + + +def set_seed(seed: int) -> None: + """设置全局随机种子。 + + Examples + -------- + >>> set_seed(42) + """ + # TODO(L0) + raise NotImplementedError diff --git a/src/mcm_toolkit/core/result.py b/src/mcm_toolkit/core/result.py new file mode 100644 index 0000000..b690379 --- /dev/null +++ b/src/mcm_toolkit/core/result.py @@ -0,0 +1,51 @@ +"""统一结果对象。 + +第一版只有**一个** :class:`Result`(原建议里的 5 个 Result 类属于过早分类)。 +任何"拟合 / 求解 / 模拟 / 评价"函数都只返回它,不返回裸 tuple。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class Result: + """所有计算型函数的统一返回值。 + + Parameters + ---------- + name: + 产生该结果的方法名,例如 `"monte_carlo"`。 + params: + 参数估计 / 决策变量取值 / 权重等"输入侧"的结论。 + metrics: + 评估指标,例如 `{"rmse": 0.31, "r2": 0.94}`。 + data: + 主要载荷:预测值、模拟轨迹、排序结果、收益数组等。 + success: + 是否正常完成。失败时仍返回 Result,但 `success=False` 且 `message` 说明原因。 + message: + 给人看的一句话说明,可直接抄进论文的"方法说明"。 + + 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) + + # TODO(L0): 实现 __repr__,打印成"方法名 + 指标表"的紧凑形式, + # 方便在 notebook 里直接肉眼检查、或复制进论文草稿。 + # TODO(L0): 实现 summary() -> str,返回可直接贴进论文的 Markdown 表格。 + def __repr__(self) -> str: # pragma: no cover - 待实现 + raise NotImplementedError diff --git a/src/mcm_toolkit/core/validate.py b/src/mcm_toolkit/core/validate.py new file mode 100644 index 0000000..4cf6129 --- /dev/null +++ b/src/mcm_toolkit/core/validate.py @@ -0,0 +1,54 @@ +"""参数校验。 + +目的不是"教用户用库",而是**把错误提前到调用点**: +比赛时最贵的是 debug 时间,一个 `ValueError: 数组长度不一致` 比 +十层 traceback 里的 `IndexError` 值钱得多。 +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +__all__ = [ + "check_array", + "check_probability", + "check_same_length", + "check_square_matrix", +] + + +def check_array(x: Any, ndim: int | None = None, finite: bool = True) -> np.ndarray: + """转成 ndarray 并做基本检查(维度、有限值)。 + + Examples + -------- + >>> check_array([1, 2, 3]).shape + (3,) + """ + # TODO(L0) + raise NotImplementedError + + +def check_same_length(*arrays: Any) -> None: + """检查多个数组长度一致,不一致时抛出带长度明细的 ValueError。 + + Examples + -------- + >>> check_same_length([1, 2], [3, 4]) + """ + # TODO(L0) + raise NotImplementedError + + +def check_probability(p: float) -> float: + """检查 p 落在 [0, 1],返回 float。""" + # TODO(L0) + raise NotImplementedError + + +def check_square_matrix(a: Any) -> np.ndarray: + """检查是方阵,返回 ndarray。AHP 判断矩阵、转移矩阵都要用。""" + # TODO(L0) + raise NotImplementedError diff --git a/src/mcm_toolkit/data/__init__.py b/src/mcm_toolkit/data/__init__.py new file mode 100644 index 0000000..89b9acd --- /dev/null +++ b/src/mcm_toolkit/data/__init__.py @@ -0,0 +1,26 @@ +"""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", +] diff --git a/src/mcm_toolkit/data/clean.py b/src/mcm_toolkit/data/clean.py new file mode 100644 index 0000000..8257261 --- /dev/null +++ b/src/mcm_toolkit/data/clean.py @@ -0,0 +1,81 @@ +"""缺失值、离群值、缩尾。 + +`detect_outliers` 属于 S 档:不只是"算出来",还要能解释给队友听 +为什么要踢掉这几个点(论文里必须交代),所以配完整函数卡片 +(docs/dev/function-card-template.md)。 +""" + +from __future__ import annotations + +from typing import Any, Literal + +__all__ = ["clean_missing", "detect_outliers", "impute_missing", "winsorize"] + + +def clean_missing(data: Any, axis: int = 0, threshold: float | None = None) -> Any: + """删除缺失过多的行/列。 + + Parameters + ---------- + axis: + 0 = 按行删,1 = 按列删。 + threshold: + 缺失比例超过该值就删;`None` 表示只要有缺失就删。 + + Examples + -------- + >>> clean_missing(df, threshold=0.5) # doctest: +SKIP + """ + # TODO(L0) + raise NotImplementedError + + +def impute_missing(data: Any, method: str = "mean", **kwargs: Any) -> Any: + """缺失值填补。 + + Parameters + ---------- + method: + `"mean"` / `"median"` / `"mode"` / `"ffill"` / `"interpolate"` / `"knn"`。 + + Notes + ----- + 论文里必须说明用了哪种填补、为什么。填 `mean` 之前先想一下 + "这个缺失是不是非随机缺失(MNAR)",否则结论可能被填补方式带偏。 + """ + # TODO(L0) + raise NotImplementedError + + +def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwargs: Any) -> Any: + """标出离群点位置(返回布尔掩码,不直接删)。 + + Parameters + ---------- + method: + `"iqr"`:Q1-1.5·IQR 之外(`k` 可调,默认 1.5); + `"zscore"`:|z| > threshold(默认 3.0)。 + + Returns + ------- + 与原数据同形的布尔 DataFrame/ndarray,`True` 表示该点是离群点。 + + Notes + ----- + **只标不删**是刻意的:删点是有代价的判断,必须由人来做, + 并且要在论文里写清楚删了几个、依据是什么。 + + Examples + -------- + >>> mask = detect_outliers([1, 2, 3, 4, 100], method="iqr") + >>> bool(mask[-1]) + True + """ + # TODO(L0) ★ S 档:配完整函数卡片 + raise NotImplementedError + + +def winsorize(data: Any, lower: float = 0.01, upper: float = 0.99) -> Any: + """缩尾:把超出分位数的值压到分位点,而不是删除。""" + # TODO(L1) + raise NotImplementedError diff --git a/src/mcm_toolkit/data/io.py b/src/mcm_toolkit/data/io.py new file mode 100644 index 0000000..c381f62 --- /dev/null +++ b/src/mcm_toolkit/data/io.py @@ -0,0 +1,37 @@ +"""读写表格。 + +只保留两个函数:`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 + +__all__ = ["load_table", "save_table"] + + +def load_table(path: str | Path, **kwargs: Any) -> Any: + """按扩展名自动选择读取方式,返回 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"`、`"-"`。 + + Examples + -------- + >>> df = load_table("data.csv") # doctest: +SKIP + >>> df.shape # doctest: +SKIP + """ + # TODO(L0): 编码探测 + 分派;Excel 缺 openpyxl 时给出可读报错。 + raise NotImplementedError + + +def save_table(df: Any, path: str | Path, index: bool = False, **kwargs: Any) -> Path: + """保存 DataFrame,返回实际写入的路径。""" + # TODO(L0) + raise NotImplementedError diff --git a/src/mcm_toolkit/data/sample.py b/src/mcm_toolkit/data/sample.py new file mode 100644 index 0000000..5e04311 --- /dev/null +++ b/src/mcm_toolkit/data/sample.py @@ -0,0 +1,46 @@ +"""数据集划分与重采样。 + +原建议里的 `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 diff --git a/src/mcm_toolkit/data/transform.py b/src/mcm_toolkit/data/transform.py new file mode 100644 index 0000000..b6f7b1f --- /dev/null +++ b/src/mcm_toolkit/data/transform.py @@ -0,0 +1,58 @@ +"""变换与无量纲化。 + +**重要约定(见 docs/02-architecture.md §4.4)**: +本模块只做"无量纲化",**不改变指标方向**。 +效益型 / 成本型的方向问题归 `decision` 包处理,用 `benefit_columns` 显式声明, +不允许默认假设"越大越好"——评价类题目翻车基本都翻在这里。 +""" + +from __future__ import annotations + +from typing import Any + +__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 + ---------- + method: + `"minmax"`:`(x - min) / (max - min)`,线性压缩到 `feature_range`; + `"vector"`:`x / ||x||`,保持"份额"含义(熵权法前处理常用); + `"max"`:`x / max(x)`。 + + Notes + ----- + minmax 对离群值极度敏感:一个 100 倍大的异常值会把其余所有点压成一条线。 + 先跑 `detect_outliers` 再决定。 + + Examples + -------- + >>> normalize([1, 2, 3]).tolist() + [0.0, 0.5, 1.0] + """ + # TODO(L0) + raise NotImplementedError + + +def standardize(x: Any, ddof: int = 0) -> Any: + """标准化 `(x - mean) / std`,输出均值 0、标准差 1。""" + # TODO(L0) + raise NotImplementedError + + +def log_transform(x: Any, offset: float | None = None) -> Any: + """对数变换,用于压缩右偏分布。 + + `offset=None` 时自动取 `1 - min(x)`(含 0 或负值时)保证定义域。 + """ + # TODO(L1) + raise NotImplementedError + + +def one_hot_encode(data: Any, columns: list[str] | None = None, drop_first: bool = False) -> Any: + """类别变量独热编码。""" + # TODO(L1) + raise NotImplementedError diff --git a/src/mcm_toolkit/decision/__init__.py b/src/mcm_toolkit/decision/__init__.py new file mode 100644 index 0000000..d0766d5 --- /dev/null +++ b/src/mcm_toolkit/decision/__init__.py @@ -0,0 +1,26 @@ +"""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", +] diff --git a/src/mcm_toolkit/decision/ahp.py b/src/mcm_toolkit/decision/ahp.py new file mode 100644 index 0000000..3846c00 --- /dev/null +++ b/src/mcm_toolkit/decision/ahp.py @@ -0,0 +1,57 @@ +"""层次分析法(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 diff --git a/src/mcm_toolkit/decision/grey.py b/src/mcm_toolkit/decision/grey.py new file mode 100644 index 0000000..0abb920 --- /dev/null +++ b/src/mcm_toolkit/decision/grey.py @@ -0,0 +1,71 @@ +"""灰色系统:关联分析与 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 diff --git a/src/mcm_toolkit/decision/topsis.py b/src/mcm_toolkit/decision/topsis.py new file mode 100644 index 0000000..df072e3 --- /dev/null +++ b/src/mcm_toolkit/decision/topsis.py @@ -0,0 +1,59 @@ +"""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 diff --git a/src/mcm_toolkit/decision/weighting.py b/src/mcm_toolkit/decision/weighting.py new file mode 100644 index 0000000..a659a9a --- /dev/null +++ b/src/mcm_toolkit/decision/weighting.py @@ -0,0 +1,71 @@ +"""客观赋权:熵权法、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 diff --git a/src/mcm_toolkit/models/__init__.py b/src/mcm_toolkit/models/__init__.py new file mode 100644 index 0000000..a87370e --- /dev/null +++ b/src/mcm_toolkit/models/__init__.py @@ -0,0 +1,41 @@ +"""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", +] diff --git a/src/mcm_toolkit/models/base.py b/src/mcm_toolkit/models/base.py new file mode 100644 index 0000000..1fd657e --- /dev/null +++ b/src/mcm_toolkit/models/base.py @@ -0,0 +1,31 @@ +"""模型协议。 + +所有 `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),便于直接拼成对比表。""" + ... diff --git a/src/mcm_toolkit/models/metrics.py b/src/mcm_toolkit/models/metrics.py new file mode 100644 index 0000000..17159f2 --- /dev/null +++ b/src/mcm_toolkit/models/metrics.py @@ -0,0 +1,94 @@ +"""回归/分类指标 + 模型对比。 + +原建议把这批函数同时放在 `regression/`、`utils/metrics.py` 和第 26 节的 +`analysis/` 三处,这里统一到一处。 + +注意:本模块被 `models` 与 `solve` 共用,属于"公共数学",但为了少建一个包, +放在 `models` 下;`solve` 单向依赖 `models` 是允许的。 +""" + +from __future__ import annotations + +from typing import Any + +__all__ = [ + "adjusted_r2", + "compare_models", + "mae", + "mape", + "r2_score", + "rmse", + "smape", +] + + +def rmse(y_true: Any, y_pred: Any) -> float: + """均方根误差。与 y 同量纲,最常用。""" + # TODO(L0) + raise NotImplementedError + + +def mae(y_true: Any, y_pred: Any) -> float: + """平均绝对误差。对离群值比 RMSE 稳健。""" + # TODO(L0) + raise NotImplementedError + + +def mape(y_true: Any, y_pred: Any) -> float: + """平均绝对百分比误差。无量纲,但 y 含 0 时会炸(用 smape 替代)。""" + # TODO(L1) + raise NotImplementedError + + +def smape(y_true: Any, y_pred: Any) -> float: + """对称 MAPE,y 含 0 也安全。""" + # TODO(L1) + raise NotImplementedError + + +def r2_score(y_true: Any, y_pred: Any) -> float: + """决定系数 R²。注意:加变量一定会升高,跨模型比较要看 adjusted_r2。""" + # TODO(L0) + raise NotImplementedError + + +def adjusted_r2(y_true: Any, y_pred: Any, n_features: int) -> float: + """调整 R²,惩罚变量个数。""" + # TODO(L1) + raise NotImplementedError + + +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, +) -> Any: + """把若干模型放在一起打分,返回对比表。 + + 这是赛题里最省时间的函数之一。队友问"要不要上复杂模型"时, + 跑一次这个就能用数字回答,不用争论半小时。 + + Parameters + ---------- + models: + `{"linear": m1, "poly3": m2, "random_forest": m3}`。 + 每个模型需满足 :class:`mcm_toolkit.models.base.Model` 协议(有 `fit`/`predict`)。 + cv: + 交叉验证折数;`cv=0` 表示在全部数据上拟合后打分(只看拟合能力时用)。 + + Returns + ------- + DataFrame:行 = 模型,列 = 指标;按第一列指标升序(越小越好)或降序排列。 + 可直接 `save_table(result, "compare.tex", fmt="latex")` 贴进论文。 + + Examples + -------- + >>> tbl = compare_models({"linear": m1, "poly": m2}, X, y) # doctest: +SKIP + >>> tbl.index[0] # doctest: +SKIP + 'poly' + """ + # TODO(L1) + raise NotImplementedError diff --git a/src/mcm_toolkit/models/ml.py b/src/mcm_toolkit/models/ml.py new file mode 100644 index 0000000..6587df2 --- /dev/null +++ b/src/mcm_toolkit/models/ml.py @@ -0,0 +1,88 @@ +"""机器学习薄封装。 + +对应原建议的 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 diff --git a/src/mcm_toolkit/models/regression.py b/src/mcm_toolkit/models/regression.py new file mode 100644 index 0000000..a2157c9 --- /dev/null +++ b/src/mcm_toolkit/models/regression.py @@ -0,0 +1,70 @@ +"""回归(自己封装,底层调用成熟库)。 + +对应原建议的 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 diff --git a/src/mcm_toolkit/models/timeseries.py b/src/mcm_toolkit/models/timeseries.py new file mode 100644 index 0000000..651c64a --- /dev/null +++ b/src/mcm_toolkit/models/timeseries.py @@ -0,0 +1,67 @@ +"""时间序列(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 diff --git a/src/mcm_toolkit/solve/__init__.py b/src/mcm_toolkit/solve/__init__.py new file mode 100644 index 0000000..c773017 --- /dev/null +++ b/src/mcm_toolkit/solve/__init__.py @@ -0,0 +1,58 @@ +"""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", +] diff --git a/src/mcm_toolkit/solve/dynamics.py b/src/mcm_toolkit/solve/dynamics.py new file mode 100644 index 0000000..f174810 --- /dev/null +++ b/src/mcm_toolkit/solve/dynamics.py @@ -0,0 +1,79 @@ +"""微分方程与动力系统(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 diff --git a/src/mcm_toolkit/solve/graph.py b/src/mcm_toolkit/solve/graph.py new file mode 100644 index 0000000..7054c51 --- /dev/null +++ b/src/mcm_toolkit/solve/graph.py @@ -0,0 +1,86 @@ +"""图与网络(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 diff --git a/src/mcm_toolkit/solve/optimize.py b/src/mcm_toolkit/solve/optimize.py new file mode 100644 index 0000000..0872f4b --- /dev/null +++ b/src/mcm_toolkit/solve/optimize.py @@ -0,0 +1,234 @@ +"""优化。 + +分层(对应原建议的 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 diff --git a/src/mcm_toolkit/solve/sensitivity.py b/src/mcm_toolkit/solve/sensitivity.py new file mode 100644 index 0000000..1834fec --- /dev/null +++ b/src/mcm_toolkit/solve/sensitivity.py @@ -0,0 +1,214 @@ +"""敏感性分析与鲁棒性检验。 + +★ 建议做成招牌模块。原建议这一节方向完全正确,只是函数名偏多, +已合并同义项、删掉 `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 diff --git a/src/mcm_toolkit/solve/simulate.py b/src/mcm_toolkit/solve/simulate.py new file mode 100644 index 0000000..cedadd6 --- /dev/null +++ b/src/mcm_toolkit/solve/simulate.py @@ -0,0 +1,218 @@ +"""随机模拟: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 + +__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 = 10000, + random_state: int | None = None, + **kwargs: Any, +) -> Any: + """通用蒙特卡洛:采样 → 仿真 → 汇总。 + + Parameters + ---------- + sampler: + `f(rng, **kwargs) -> 一组参数`,例如从先验分布里抽参数。 + simulator: + `f(params) -> float`,例如"给定参数下的年收益"。 + n: + 模拟次数。**必须向量化**:`simulator` 内部不要写 Python 循环, + 否则 10^4 次能跑几分钟,10^6 次直接放弃。 + + Returns + ------- + `Result`:`data` 是 `n` 个结果组成的数组, + `metrics={"mean", "std", "median", "p05", "p95", "ci_low", "ci_high"}`。 + + Notes + ----- + 比赛里 Monte Carlo 的收敛速度是 `O(1/√n)`—— + 想少一半误差要 4 倍样本。所以论文里要给"区间随 n 收敛"的图, + 证明 `n` 已经够大(这也是 `viz.plot_convergence` 的典型用法)。 + + Examples + -------- + >>> import numpy as np + >>> r = monte_carlo(lambda rng, **k: rng.normal(0, 1), np.mean, n=1000, random_state=0) + """ + # TODO(L1) ★ S 档 + raise NotImplementedError + + +def monte_carlo_probability( + event: Callable[..., bool], + sampler: Callable[..., Any], + n: int = 10000, + confidence: float = 0.95, + random_state: int | None = None, +) -> Any: + """估计事件发生概率,并给出区间。 + + Returns + ------- + `Result`:`metrics={"probability", "ci_low", "ci_high", "std_error"}`。 + + Notes + ----- + 概率估计的相对误差 ≈ `1/√(n·p)`。**小概率事件(p < 1e-3)用朴素 MC 是不可行的**, + 需要重要性采样或方差缩减——遇到这类题要提前意识到,别硬跑。 + + Examples + -------- + >>> import numpy as np + >>> r = monte_carlo_probability(lambda v: v > 0, lambda rng, **k: rng.normal(0, 1), n=2000, random_state=0) + >>> abs(r.metrics["probability"] - 0.5) < 0.1 + True + """ + # TODO(L1) ★ S 档 + raise NotImplementedError + + +def monte_carlo_integral( + func: Callable[..., Any], + bounds: Any, + n: int = 10000, + random_state: int | None = None, +) -> Any: + """用蒙特卡洛求多重积分。 + + Parameters + ---------- + bounds: + `[(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.data - 1 / 3) < 0.01 + True + """ + # TODO(L1) ★ S 档:配完整函数卡片 + raise NotImplementedError + + +def random_walk( + n_steps: int, + n_paths: int = 1, + step_sampler: Callable[..., Any] | None = None, + start: float = 0.0, + random_state: int | None = None, +) -> Any: + """随机游走轨迹。 + + Parameters + ---------- + step_sampler: + `f(rng, size) -> 增量`,默认为 ±1 等概率(对称随机游走)。 + + Returns + ------- + `Result`:`data` 形状 `(n_paths, n_steps + 1)`。 + + Examples + -------- + >>> r = random_walk(n_steps=100, n_paths=5, random_state=0) + >>> r.data.shape + (5, 101) + """ + # TODO(L1) + raise NotImplementedError + + +def markov_simulate( + transition: Any, + initial: Any, + steps: int = 100, + n_paths: int = 1, + random_state: int | None = None, +) -> Any: + """马尔可夫链模拟,返回状态序列。""" + # TODO(L1) + raise NotImplementedError + + +def markov_steady_state(transition: Any, max_iter: int = 10000, tol: float = 1e-10) -> Any: + """平稳分布。 + + Parameters + ---------- + transition: + 行随机矩阵(每行和为 1)。 + + Returns + ------- + `Result`:`data` 是平稳分布向量,`metrics={"converged", "iterations"}`。 + + Notes + ----- + 用迭代法而不是特征分解:迭代法能顺便告诉你"收敛快不快", + 而收敛速度本身就是论文里值得写一句的性质。 + **不可约 + 非周期**才有唯一平稳分布,周期链会不收敛,要检查。 + + 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 + """ + # TODO(L1) + raise NotImplementedError + + +def mm1_metrics(arrival_rate: float, service_rate: float) -> Any: + """M/M/1 排队系统的闭式指标。 + + Parameters + ---------- + arrival_rate, service_rate: + 到达率 λ 与服务率 μ。`λ >= μ` 时系统不稳定,队列长度无界—— + 这种情况下函数返回 `success=False` 并说明原因,而不是返回 `inf`。 + + Returns + ------- + `Result`:`metrics={"rho", "L", "Lq", "W", "Wq"}` + (系统利用率、系统内平均顾客数、队列长度、平均逗留时间、平均等待时间)。 + + 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 + """ + # TODO(L1) + raise NotImplementedError diff --git a/src/mcm_toolkit/stats/__init__.py b/src/mcm_toolkit/stats/__init__.py new file mode 100644 index 0000000..4d6850f --- /dev/null +++ b/src/mcm_toolkit/stats/__init__.py @@ -0,0 +1,32 @@ +"""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, + fit_distribution, + goodness_of_fit, + t_test, +) + +__all__ = [ + "describe", + "zscore", + "coefficient_of_variation", + "correlation_matrix", + "confidence_interval", + "t_test", + "anova", + "chi_square_test", + "fit_distribution", + "goodness_of_fit", + "bootstrap_statistic", + "bootstrap_ci", +] diff --git a/src/mcm_toolkit/stats/bootstrap.py b/src/mcm_toolkit/stats/bootstrap.py new file mode 100644 index 0000000..fc522df --- /dev/null +++ b/src/mcm_toolkit/stats/bootstrap.py @@ -0,0 +1,71 @@ +"""Bootstrap 重采样。 + +★ S 档模块:建议自己完整实现一遍。 +它是"没有解析解时怎么给区间"的通用答案,赛题里出现频率极高, +而且实现只有几十行——投入产出比最高的一个算法。 +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +__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 + ---------- + statistic: + 任意把样本映射成标量的函数,默认 `np.mean`。 + 换成 `np.median` / `np.std` / 自定义的 `lambda s: s.max() - s.min()` 都能用, + 这是 bootstrap 相对于 `mean ± z·s/√n` 的核心优势。 + method: + `"percentile"`:直接取经验分布的 α/2 与 1-α/2 分位(最常用,默认); + `"bca"`:偏差校正 + 加速,区间更准但慢,样本有偏时用。 + + Returns + ------- + `Result`:`params={"estimate", "low", "high", "confidence", "n_resamples"}`。 + + Notes + ----- + * 重采样时必须**按块处理**,不要一次生成 `(10000, n)` 的整矩阵(n 大时会爆内存)。 + * 样本量 n < 10 时 bootstrap 不可靠,先补数据。 + * 比赛写论文时要注明:n_resamples、seed、用的是 percentile 还是 BCa。 + + 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 + """ + # TODO(L0) ★ S 档:配完整函数卡片 + raise NotImplementedError diff --git a/src/mcm_toolkit/stats/descriptive.py b/src/mcm_toolkit/stats/descriptive.py new file mode 100644 index 0000000..9170bb0 --- /dev/null +++ b/src/mcm_toolkit/stats/descriptive.py @@ -0,0 +1,40 @@ +"""描述统计。 + +每个函数都只是"一句话数学含义 + 一句话什么时候用"(A 档), +不单独写文档卡片。 +""" + +from __future__ import annotations + +from typing import Any + +__all__ = ["coefficient_of_variation", "describe", "zscore"] + + +def describe(x: Any, weights: Any | None = None) -> Any: + """一张表看完集中趋势、离散程度、偏度峰度、缺失情况。 + + `weights` 非空时给出加权均值与加权标准差(权重是"频数"或"重要性"时用)。 + + Returns + ------- + DataFrame,行是指标,列是被描述的变量。 + + Examples + -------- + >>> describe(df).loc["mean"] # doctest: +SKIP + """ + # TODO(L0) + raise NotImplementedError + + +def zscore(x: Any, ddof: int = 0) -> Any: + """标准化到 z 分数,顺带回答了"这个值离均值几个标准差"。""" + # TODO(L0) + raise NotImplementedError + + +def coefficient_of_variation(x: Any) -> float: + """变异系数 `std / mean` —— 跨量纲比较"谁更波动"时的唯一正确做法。""" + # TODO(L1) + raise NotImplementedError diff --git a/src/mcm_toolkit/stats/inference.py b/src/mcm_toolkit/stats/inference.py new file mode 100644 index 0000000..7911e07 --- /dev/null +++ b/src/mcm_toolkit/stats/inference.py @@ -0,0 +1,114 @@ +"""统计推断:区间估计、假设检验、分布拟合。 + +原建议里的 `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 + +__all__ = [ + "anova", + "chi_square_test", + "confidence_interval", + "correlation_matrix", + "fit_distribution", + "goodness_of_fit", + "t_test", +] + + +def correlation_matrix(data: Any, method: str = "pearson", min_periods: int = 1) -> Any: + """相关矩阵 + 显著性星标。 + + Returns + ------- + DataFrame,值 = 相关系数;`min_periods` 控制缺失值容差。 + + Examples + -------- + >>> correlation_matrix(df).round(3) # doctest: +SKIP + """ + # TODO(L0) + raise NotImplementedError + + +def confidence_interval( + x: Any, + confidence: float = 0.95, + method: str = "normal", +) -> tuple[float, float]: + """均值或比例的置信区间。 + + Parameters + ---------- + method: + `"normal"`:`mean ± z·s/√n`(n 大时用); + `"t"`:小样本用 t 分布(更保守,n < 30 建议用这个); + `"proportion"`:比例的标准误 `√(p(1-p)/n)`。 + + Examples + -------- + >>> lo, hi = confidence_interval([1, 2, 3, 4, 5], confidence=0.95) + """ + # TODO(L0) + raise NotImplementedError + + +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` 时是单样本检验。 + + Returns + ------- + dict:`{"statistic", "pvalue", "df", "significant", "conclusion"}`。 + `conclusion` 是一句能直接抄进论文的中文结论。 + """ + # TODO(L1) + raise NotImplementedError + + +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 diff --git a/src/mcm_toolkit/viz/__init__.py b/src/mcm_toolkit/viz/__init__.py new file mode 100644 index 0000000..7dedaa6 --- /dev/null +++ b/src/mcm_toolkit/viz/__init__.py @@ -0,0 +1,21 @@ +"""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", +] diff --git a/src/mcm_toolkit/viz/export.py b/src/mcm_toolkit/viz/export.py new file mode 100644 index 0000000..d274cd7 --- /dev/null +++ b/src/mcm_toolkit/viz/export.py @@ -0,0 +1,74 @@ +"""图表导出。 + +工具箱的最终消费者是**论文**,所以这一层不是"附赠功能", +而是原建议里最被低估的部分(它只给了一句 `save_figure`)。 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +__all__ = ["save_figure", "save_table"] + + +def save_figure( + fig: Any, + path: str | Path, + dpi: int = 300, + formats: tuple[str, ...] = ("png", "pdf"), + bbox_inches: str = "tight", + transparent: bool = False, +) -> list[Path]: + """保存图,默认同时出 PNG(预览用)和 PDF(LaTeX 插图用)。 + + Parameters + ---------- + formats: + 美赛论文如果用 LaTeX 排版,**矢量图(PDF/EPS)比 PNG 清晰得多**, + 所以默认两种都出。 + + Returns + ------- + 实际写出的文件路径列表。 + + Notes + ----- + 建议配合项目的 figure 目录约定:`figures/fig{序号}_{短名}.pdf`, + 图号与正文引用一一对应,最后核对时不容易错位。 + """ + # TODO(L0) + raise NotImplementedError + + +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 diff --git a/src/mcm_toolkit/viz/plot.py b/src/mcm_toolkit/viz/plot.py new file mode 100644 index 0000000..0c2baf3 --- /dev/null +++ b/src/mcm_toolkit/viz/plot.py @@ -0,0 +1,79 @@ +"""绘图。 + +`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 diff --git a/src/mcm_toolkit/viz/style.py b/src/mcm_toolkit/viz/style.py new file mode 100644 index 0000000..3ebaada --- /dev/null +++ b/src/mcm_toolkit/viz/style.py @@ -0,0 +1,40 @@ +"""统一图样式。 + +美赛送稿要求:**双栏排版、字号不能太小、图必须能在黑白打印下区分**。 +把这些要求固化成一次调用,比每次手调 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 diff --git a/tables/.gitkeep b/tables/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6174d7d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +"""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], + ] + ) diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py new file mode 100644 index 0000000..942cdbe --- /dev/null +++ b/tests/test_api_surface.py @@ -0,0 +1,67 @@ +"""公开 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) diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..7c8f5b1 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,69 @@ +"""core 包测试。 + +TODO(L0):实现后去掉 skip 标记。 +""" + +from __future__ import annotations + +import numpy as np +import pytest + + +@pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0") +class TestRandom: + def test_get_rng_is_reproducible(self): + from mcm_toolkit.core import get_rng + + 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_set_seed_makes_global_reproducible(self): + from mcm_toolkit.core import set_seed + + set_seed(7) + a = np.random.default_rng(7).random() + set_seed(7) + b = np.random.default_rng(7).random() + assert a == b + + +@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]]) + + +@pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0") +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 diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..f3eda1c --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,65 @@ +"""data 包测试。""" + +from __future__ import annotations + +import numpy as np +import pytest + +pytestmark = pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0/L1") + + +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 mask.sum() == 3 + assert 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_normalize_minmax_range(rng): + 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_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_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 + + +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) == ["城市", "人口"] diff --git a/tests/test_decision.py b/tests/test_decision.py new file mode 100644 index 0000000..5cd848a --- /dev/null +++ b/tests/test_decision.py @@ -0,0 +1,79 @@ +"""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 # 后验差一级 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..56acd5f --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,58 @@ +"""models 包测试。""" + +from __future__ import annotations + +import numpy as np +import pytest + +pytestmark = pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L1") + + +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) + + +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) + + +def test_compare_models_returns_table_sorted_by_first_metric(linear_data): + from mcm_toolkit.models import compare_models, fit_linear, fit_polynomial + + x, y = linear_data + X = x.reshape(-1, 1) + tbl = compare_models( + {"linear": fit_linear(X, y), "poly2": fit_polynomial(X, y, degree=2)}, + X, + y, + cv=0, + ) + assert set(tbl.index) >= {"linear", "poly2"} + assert "rmse" in tbl.columns diff --git a/tests/test_solve.py b/tests/test_solve.py new file mode 100644 index 0000000..a4cf1d6 --- /dev/null +++ b/tests/test_solve.py @@ -0,0 +1,85 @@ +"""solve 包测试。""" + +from __future__ import annotations + +import numpy as np +import pytest + +pytestmark = pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0/L1") + + +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 abs(r.data - 1 / 3) < 0.01 + + +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, + lambda rng, **kw: rng.standard_normal(), + n=5000, + random_state=0, + ) + assert abs(r.metrics["probability"] - 0.5) < 0.05 + + +def test_monte_carlo_is_reproducible(): + from mcm_toolkit.solve import monte_carlo_probability + + args = (lambda z: z > 0, lambda rng, **kw: rng.standard_normal()) + a = monte_carlo_probability(*args, n=1000, random_state=9) + b = monte_carlo_probability(*args, n=1000, random_state=9) + assert a.metrics["probability"] == b.metrics["probability"] + + +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 np.allclose(r.data, [2 / 3, 1 / 3], atol=1e-6) + + +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) + + +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(): + from mcm_toolkit.solve import mm1_metrics + + r = mm1_metrics(arrival_rate=2, service_rate=3) + assert np.isclose(r.metrics["rho"], 2 / 3, rtol=1e-6) + assert np.isclose(r.metrics["L"], 2.0, rtol=1e-6) + + +def test_mm1_rejects_unstable_system(): + """λ >= μ 时系统不稳定,应 success=False 而不是返回 inf。""" + from mcm_toolkit.solve import mm1_metrics + + assert mm1_metrics(arrival_rate=3, service_rate=3).success is False + + +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) diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..918c705 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,67 @@ +"""stats 包测试。 + +原则(见 docs/02-architecture.md §5):**测已知解析解,不测实现细节**。 +""" + +from __future__ import annotations + +import numpy as np +import pytest + +pytestmark = pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L1") + + +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_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_correlation_matrix_diagonal_is_one(rng): + from mcm_toolkit.stats import correlation_matrix + + x = rng.normal(size=(200, 4)) + c = correlation_matrix(x) + assert np.allclose(np.diag(np.asarray(c)), 1.0) + + +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 diff --git a/tests/test_viz.py b/tests/test_viz.py new file mode 100644 index 0000000..de1884a --- /dev/null +++ b/tests/test_viz.py @@ -0,0 +1,52 @@ +"""viz 包测试。 + +绘图测试不比对像素——只验证"能画出来、能存成文件、尺寸符合美赛要求"。 +""" + +from __future__ import annotations + +import matplotlib + +matplotlib.use("Agg") + +import pytest + +pytestmark = pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0/L1") + + +@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) + fig, ax = plot(x, x, kind=kind) + assert fig is not None and ax is not None + + +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(tmp_path, rng): + from mcm_toolkit.viz import plot, save_figure + + fig, _ = plot(rng.normal(size=50), rng.normal(size=50), kind="scatter") + 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_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