Compare commits

..
43 Commits
Author SHA1 Message Date
guzhujushi f99cf5c3c9 更新了保存图表模块 2026-10-20 08:47:15 +08:00
guzhujushi 748affb7f0 feat(models): 落地 compare_models(指标名别名映射)+ 同步你的三条入口口径
你的三条改动(决策 61,已复测入库):
- verify_yt_yp 拦 Inf:rmse([1.0, inf], ...) 从安静返回 inf 变成 ValueError。
- 至少 2 个样本:rmse(1.0, 1.0) 不再被当成 1 个样本算出 0.0。
- mape 从"只拦 0"改成拦 y_true <= 0:原来对负数返回 -1.0(无意义)。
Codex 侧把这三条写进 7 处 Raises(易错点 30 复发 1 次),
test_mape_rejects_zero_in_y_true 的 match 文案改为"非正数",另补 Inf / 样本数两条测试。

compare_models(决策 62,A 档,Codex 实现):
- 别名映射 _METRIC_FUNCS:r2 与 r2_score 指向同一个函数,传哪个列名就叫哪个,
  两个一起传报 ValueError;adjusted_r2 不进表(需要 n_features)。
- cv=0 全量拟合后打分;cv>=2 用手写 K 折(get_rng(random_state).permutation +
  np.array_split),不引入 sklearn 依赖;报折间均值;每折至少 2 个样本 => cv <= n // 2。
- 排序方向跟第一个指标走(误差类升序、R² 降序);models 必须传模型对象(fit/predict),
  X 必须二维,metrics 必须是 tuple/list。
- 原 test_compare_models_... 的用法是错的(传拟合结果当模型),改成测试桩后摘 skip。

tests/test_models.py:23 -> 31 条(排序两条、别名、坏指标名 / 坏 models / 坏 cv、
每折下限、random_state 可复现)。

三连:pytest 233 passed / 25 skipped(258 收集)、ruff 0、pyright 0。

文档:STATUS 决策 61 / 62 + 第十轮(第九轮那 4 条标办结);CHANGELOG 本轮条目;
personal-pitfalls 易错点 30 记复发 1 次 + 新增 33(多行文案少引号 / ValueError 多参数)。
2026-10-12 16:53:01 +08:00
guzhujushi 9907289ec8 feat(models): 审查 metrics.py:工程侧收口 + 修 r2/smape 两个真 bug(+18 条测试)
models/metrics.py(用户写、Codex 审):6 个回归指标 + 共享入口 verify_yt_yp 落地,
compare_models 仍是 TODO(L1)。

真 bug(决策 59,由用户亲手改定):
- r2_score 的 SS_tot 基准用了 np.mean(y_pred),必须是 np.mean(y_true)。
  探针实测:修前 0.8803827751196172、修后 0.875 = sklearn.metrics.r2_score 一致;
  adjusted_r2 复用它,一并修正。完美拟合(r2_score(y, y) == 1)与常量 y 都掩盖了这个错,
  所以新测试特意用"非完美拟合"的数据。ss_tol 顺带改名 ss_tot。
- smape 的 np.where 掩码防不住零除(两条分支都会被求值),在
  filterwarnings = ["error::RuntimeWarning"] 下任何含 0 的用例直接红 → 改 np.divide(where=),
  数值结果不变(决策 60)。

工程侧(决策 47):报错文案统一为"参数名 + 实际值";补 7 处 docstring
(Parameters / Returns / Raises,把 NaN 报错、mape 遇 0、r2 常量 y、adjusted_r2 分母保护
写成契约;n_features 注明不含截距);ruff 9 条清零(6 F541 + 3 W293)。

tests/test_models.py:5 条 → 23 条(2 条摘 skip 转正 + 18 条新增)。

三连:pytest 224 passed / 26 skipped(250 收集)、ruff 0、pyright 0。

文档:STATUS 决策 59 / 60 + 第九轮(含 4 条待拍板口径);CHANGELOG 本轮条目;
docs/dev/personal-pitfalls.md 追加易错点 31(基准量抄错对象)与 32(np.where 掩码防不住零除)。
2026-10-10 17:07:46 +08:00
guzhujushi 3bb31f95de chore(models): 开工前清理 + 去掉 mm1 的残留注释序号
- tests/test_models.py:模块级 pytestmark -> 逐条 @pytest.mark.skip(决策 26);
  该文件的 5 条测试仍然全跳过,只是形式上与其它测试文件对齐了。
- models/metrics.py:rmse / mae / r2_score 的注释 TODO(L0) -> TODO(L1)
  (roadmap 的 L0 12 项里没有这三个指标,它们属于 L1 的 models 清单)。
- simulate.py:用户去掉 # 4. 封装返回 这行残留序号注释(无代码变化)。

校验:pytest 204 passed / 28 skipped;ruff 0;pyright 0
2026-10-10 14:01:56 +08:00
guzhujushi 9cb295a362 fix(solve): 同步第三轮入口口径(docstring + 测试 + lint)
用户第三轮改了 5 处口径:非负校验带容差、initial 收 np.integer、paths 赋值挪进 try、
metrics 换成 terminal_distribution、mm1 放开 lambda=0 并收 numpy 标量。三处算法本体仍正确
(探针复测:终点分布 {0: 0.6644, 1: 0.3356}、lambda=0 给 L=Lq=Wq=0 且 W=1/mu、data is metrics 已为 False)。

Changed(Codex 只做同步,未改口径)
- docstring:markov_simulate 的 Returns 与"遗留"段、markov_steady_state 的 Raises
  (TypeError / ValueError 分工)、mm1_metrics 的 Parameters / Returns / Raises。
- tests/test_solve.py:同步 5 条受影响的测试,新增 2 条(terminal_distribution 只列出现过的状态、
  lambda=0 的空系统)。test_solve.py 64 条。
- ruff:B905(zip 缺 strict)+ 2 条空白 -> 0;rng 之后的双空行 -> 1。
- STATUS.md:决策 56 / 57 / 58 更新到"只剩 5 条小口径";「下一步」第八轮;
  新增「第八轮备忘:models 与 decision 各是干什么的」(函数清单 / 使用顺序 / 建议开工顺序)。
- CHANGELOG 与 docs/dev/personal-pitfalls.md 易错点 30(口径一改,docstring / 测试 / STATUS 同时过期)。

校验:pytest 204 passed / 28 skipped(232 收集);ruff 0;pyright 0
2026-10-10 09:31:30 +08:00
guzhujushi 953e988983 fix(solve): 审查 mm1_metrics + 第二批入口改动(工程侧收口 + 6 条测试)
用户本轮改了 markov_simulate / markov_steady_state 的入口校验,并写完 mm1_metrics,
三处算法本体都正确(mm1 的 5 个公式经探针与闭式解逐位对拍通过,含 W = Wq + 1/mu)。
Codex 只动工程侧:

Fixed
- mm1_metrics 的稳定性分界 "<" 应为 "<="(docstring 与既有测试的口径都是 lambda >= mu 不稳定):
  lambda == mu 原先掉进 try、rho == 1.0 触发 ZeroDivisionError,被 except 吞成 success=False。
  两个分支的可观测输出完全相同(success=False / metrics={} / data=None),只有 message 不同,
  故按决策 31 直接修并加回归测试,未改动任何口径。
- 三处报错文案的参数名抄错:markov_steady_state 的 max_iter <= 0 写成 "tol 必须是正数",
  mm1_metrics 的 service_rate 两条报错都写成 "tol";半角逗号统一为全角。

Changed
- mm1_metrics 的 docstring 补 Raises 与 data 说明;call_params = {...} 补空格;
  ruff 6 条(W293 x5 / F541 f-string 无占位符 / W292 文件末尾无换行)。
- markov_steady_state 的 try 前 3 个空行 -> 1;"异常: " -> "异常:"。
- tests/test_solve.py:摘掉 2 条 mm1_metrics 的 skip,新增 4 条(lambda == mu 的回归、
  非负校验回归、max_iter=3 未收敛判 success=False)。
- STATUS.md 决策 56 / 57 更新办结项 + 新增决策 58(mm1 的 4 条待拍板);CHANGELOG;易错点 29。

校验:pytest 202 passed / 28 skipped(230 收集);ruff 0;pyright 0
2026-10-09 23:52:53 +08:00
guzhujushi 0955890010 feat(solve): 审查 markov_steady_state:工程侧收口 + 8 条测试
用户实现的 markov_steady_state 经探针实测算法本体正确(3 状态与特征分解逐位一致、
200x200 一致到 1.9e-12 且只用 6 次迭代 / 0.10s),本次只动工程侧,未改任何算法行:

- ruff 5 条 W293 全清;mm1_metrics 前 4 个空行 -> 2;失败文案改成全角冒号
- docstring 补 max_iter / tol 两个参数与 Raises,写清 tol 是相邻差口径
  (实测慢混合链 tol=1e-10 -> 实际误差 4.6e-9,被 1/(1-|lambda2|) 放大约 50 倍)
- Notes 修正"周期链会不收敛"的说法:可约链的实情是平稳分布不唯一,
  迭代法给的是离初值最近的那个(均匀初值对双随机矩阵本身就是不动点)
- tests/test_solve.py:摘掉 1 条 skip + 新增 7 条(该函数共 8 条实跑)
- STATUS.md 新增决策 57(6 条待拍板口径)与第六轮「下一步」;CHANGELOG;易错点 28

校验:pytest 196 passed / 30 skipped(226 收集);ruff 0;pyright 0
2026-10-09 23:20:13 +08:00
guzhujushi dd9bd638b8 feat(solve): 审查 markov_simulate:工程侧收口 + 9 条测试
用户实现的第一版 markov_simulate 算法本体经探针实测正确(终点频率 [0.657, 0.343]
对照平稳分布 [2/3, 1/3],同种子可复现),本次只动工程侧,未改任何算法行:

- ruff 8 条全清(I001 导入排序 + 5 处 W293 / 2 处 W291)
- docstring 从一行补全为 Parameters / Returns / Raises / Notes / Examples
- 文案:马尔科夫→马尔可夫(3 处)、initail→initial、半角逗号→全角、paths[:,0]→paths[:, 0]
- arr, kind = as_array(...) 的 kind 全程未用 → arr, _
- tests/test_solve.py 新增 9 条 markov_simulate 测试(此前 0 条)
- STATUS.md 新增决策 56(initial 语义待用户拍板,A/B/C 三选项)与 5 条待办
- CHANGELOG 与 docs/dev/personal-pitfalls.md 易错点 27(入口分流后两条分支口径矛盾)

校验:pytest 188 passed / 31 skipped(219 收集);ruff 0;pyright 0
2026-10-05 23:11:04 +08:00
guzhujushi 9368dd91cb feat(solve): random_walk 的 step_sampler 契约改成"整数 size + 一维返回"(方案 B)
用户拍板方案 B(决策 55),Codex 代改。

- size 由 (n_paths, n_steps) 改成整数 n_paths * n_steps,必须返回一维;
  内部 reshape(n_paths, n_steps)(行主序);二维返回一律判失败
- 动因:方阵时转置 (n_steps, n_paths) 与目标形状相同,形状校验天生分不出来(易错点 25)
- 关键:不用 .ravel() 救场(会把转置摊平,歧义原样回来)
- 红利:与 monte_carlo 的 sampler 契约(决策 48 的 size=n)统一,采样器可直接复用(实测)
- docstring:step_sampler 契约与 Notes 同步(一维 / 行主序 / 二维必失败)
- tests:转置那条改成 rejects_2d_step_sampler(含方阵),新增"整数 size + 行主序"契约测试;solve 段 43 条
- 验证:210 收集 / 179 passed / 31 skipped / 0 xfail,ruff 0,pyright 0
- docs:STATUS 决策 55 + random_walk 待办全部办结;CHANGELOG
2026-10-05 18:06:55 +08:00
guzhujushi cd6f613568 fix(solve): random_walk 的 start 校验恢复后收尾(文案/空格/测试)
两条口径由用户第二轮改定,Codex 顺手补工程侧。

- 用户:恢复 start 的入口类型校验;n_steps/n_paths 注解放宽为 int | np.integer
- Codex:start 报错文案"必须是整数" -> "必须是数值"(这条校验本来就收 float,原文案自相矛盾),
  类型名统一 type(x).__name__;n_paths 默认值补空格;docstring 的 start 说明与 Raises 同步
- tests:start="a" 恢复断言 TypeError;补回 np.int64 的 n_steps/n_paths;solve 段 42 条
- 验证:209 收集 / 178 passed / 31 skipped / 0 xfail,ruff 0,pyright 0
- docs:STATUS 决策 55(方阵歧义两条路)+ 验证计数;CHANGELOG
2026-10-05 18:01:40 +08:00
guzhujushi 6fca82e3fc fix(solve): random_walk 失败路径的 UnboundLocalError;第二轮口径收口
口径由用户第二轮改定;Codex 修一处真 bug + 工程侧。

- 真 bug:call_params 写在 try 里、却在 except 被引用,5 种失败输入 5 个全炸 UnboundLocalError
  (本该返回 success=False 的 Result,变成把异常抛给调用方)。按原意前移到 try 之前,未改口径;
  失败时照样带上 n_steps / n_paths / start / random_state
- 用户口径:n_steps/n_paths 拆 TypeError/ValueError;加 steps 形状与 NaN 两条校验;
  默认步长改 rng.choice([-1.0, 1.0]);params 补 random_state;metrics 改 mean_terminal/std_terminal
- 工程:docstring 同步(Returns 的 metrics、Raises 的分工、去掉已删的 start 校验);+3 条测试
  (失败也必须返回带 params 的 Result、NaN 判失败、终点 std ~= sqrt(t)),solve 段 41 条
- 验证:208 收集 / 177 passed / 31 skipped / 0 xfail,ruff 0,pyright 0
- docs:STATUS 决策 54 + 3 条新口径;CHANGELOG;易错点 26
2026-10-05 17:24:23 +08:00
guzhujushi 8ec08ea9ce feat(solve): random_walk 落地;修默认采样器真 bug(integers 不收 float)
random_walk 由用户本人写;Codex 改一处真 bug + 工程侧。

- 真 bug:默认采样器 rng.integers(0, 2, size=size, dtype=float) 被 numpy 拒掉
  (Unsupported dtype dtype(float64) for integers),默认路径 100% success=False、
  data=None,连 docstring 的 Examples 都跑不通。改成 integers(size=size) * 2 - 1,
  由后面的 np.asarray(..., dtype=float) 转 float;实测 (5,101)、步长恒 ±1、同种子可复现
- 工程:ruff W293 x2;docstring 补全(n_steps/n_paths/start/random_state + Returns /
  Raises / Notes,写清 step_sampler 收二维 size=(n_paths, n_steps))
- tests:random_walk 首次有测试,新增 6 条(形状 + start、步长恒 ±1、可复现、自定义
  高斯采样器、3 类非法入参、docstring Example)
- monte_carlo_integral:用户第二轮已收 ndarray + 把三行提出 if/else(决策 52)
- 验证:205 收集 / 174 passed / 31 skipped / 0 xfail,ruff 0,pyright 0
- docs:STATUS 决策 53 + random_walk 4 条待办;CHANGELOG;易错点 24/25,第 18 条复发 +1
2026-10-05 17:04:52 +08:00
guzhujushi dcda080be4 fix(solve): monte_carlo_integral 入口收口 + 失败 message 不再被覆盖
三项待办由用户本人改定;Codex 只补 ruff / docstring / tests / 文档。

- bounds 形状校验:`[0, 1]` 从裸 IndexError 变成可读 ValueError(带形状提示);
  空 / 非 list-tuple / 参差不齐一并收口
- 失败 message:改成 if result.success / else 两支,success=False 时不再出现"成功"文案
- n 注解放宽为 int | np.integer;event 注解 Callable[..., bool] -> Callable[..., Any]
  (决策 50 的遗留清零;pyright 复测不再拒绝向量化事件函数)
- 工程侧:清掉 2 行 ruff W293;docstring 的 bounds / Raises 同步;
  非法 bounds 测试 1 类扩到 4 类,新增失败 message 语义与 np.int64 两条
- 验证:199 收集 / 168 passed / 31 skipped / 0 xfail,ruff 0,pyright 0
- docs:决策 52 + 2 条新口径(bounds 收不收 ndarray、if/else 三行重复);CHANGELOG
2026-10-05 16:04:39 +08:00
guzhujushi ed1f9f0c53 feat(solve): monte_carlo_integral 落地,修掉体积符号真 bug
实现由用户本人写;Codex 只碰工程侧 + 一处纯 bug(决策 31)。

- 真 bug:体积写成 lows - highs,奇数维为负(1D x^2 于 [0,1] 得 -0.3324、3D 得 -0.5005);
  2D 因两个负号相乘"看着对"(+0.2499),把 bug 藏住。改成 widths = highs - lows 后
  复测 0.3324 / 0.5005 / 1.9971([0,2])全部正确
- docstring:补 func / n / random_state 与 Raises;写清 metrics["mean"] 才是积分估计值、
  data 是 n 个样本各自的贡献(决策 51);修掉跑不通的 Examples(data 是数组,bool(数组) 会抛)
- tests/test_solve.py:摘 1 条 skip + 新增 4 条(3D 奇数维 / [0,2] 宽度 / 2D+params / 非法 bounds)
- 验证:197 收集 / 166 passed / 31 skipped / 0 xfail,ruff 0,pyright 0
- docs:STATUS 决策 51 + 3 条待办 + 下一站 random_walk;CHANGELOG;易错点 22/23,第 3 条复发 +1
2026-10-05 15:55:12 +08:00
guzhujushi b0c4b4a413 feat(solve): monte_carlo 系列两个函数落地(L0 11/12、L1)+ Wilson 区间;修 z_score^2 异或 bug
用户实现:
- solve.monte_carlo(L0 第 11 项,S 档):向量化采样→仿真→汇总,8 个指标;
  批量大小由实现传 sampler(rng, size=n);结果含 NaN 判失败;std 用 ddof=0。
- solve.monte_carlo_probability(L1,S 档):event(batch)->bool,Wilson score 区间夹 [0,1],
  confidence 记进 params,小概率改成 message 警告。

Codex 只碰工程侧(算法未动,唯一例外见下):
- 真 bug:z_score^2 是异或不是乘方(np.float64 ^ 2 抛 TypeError,被 except 吞成 success=False,
  函数实际不可用)-> 改 z_score**2。新增经验第 21 条。
- docstring:补齐 Parameters / Raises,修掉两处跑不通的 Examples,Wald -> Wilson 同步。
- 入口校验按 §4.7 收严 n(bool / np.integer / TypeError 与 ValueError 分工)—— 决策 49 后此类归用户,已记档。
- ruff 九条(I001 / F541 x3 / SIM300 / W291 x2 / W293 x2 等)、格式与文案。
- 测试:摘掉 2 条已实现却仍挂着的 skip(易错点 9),新增 14 条(含 vectorization、
  Wilson 对 statsmodels 的对照、NaN / 布尔 / 长度等边界),solve 段共 25 条。

校验:193 收集 / 161 passed / 32 skipped / 0 xfail;ruff 0;pyright 0。
文档:STATUS.md 决策 47-50;docs/dev/personal-pitfalls.md 第 18-21 条;CHANGELOG.md。
2026-10-05 11:26:54 +08:00
guzhujushi 58f2236703 docs(status): 记入分工硬规矩(决策 47)+「明天从这里开始」;test_solve 改逐条 skip
- STATUS.md:新增决策 47 —— 算法一律由用户本人写(即便用户改口也不代写),
  工程事务(入口/校验、tests/、lint、docstring、卡片、STATUS/CHANGELOG)由 Codex 写;
  L0 计数 9/12 -> 10/12;skip 说明 37 -> 34 条。
- STATUS.md:新增「⭐ 明天从这里开始」区块 —— 第一个要写的文件是
  src/mcm_toolkit/solve/simulate.py 的 monte_carlo(L0 11/12,S 档),
  含参考契约、向量化要求、get_rng 口径、三条测试建议、卡片路径与一个待拍板口径。
- tests/test_solve.py:模块级 pytestmark 拆成 9 条逐条 skip(决策 26、易错点 9)。
- CHANGELOG.md:同步记录。

校验:pytest 177 收集 / 143 passed / 34 skipped / 0 xfail;ruff 0;pyright 0。
2026-10-03 20:12:46 +08:00
guzhujushi a1cc26e042 feat(stats): bootstrap_ci 落地(L0 10/12,S 档)+ 卡片;修掉 rng 绕过 get_rng 与 lambda 名字死分支
## 审查结论
算法移植是对的:大样本正态下百分位区间半宽与解析 `2 × 1.96 × se` 差 < 2%,
按块重采样(batch_size=1000)也照 docstring 的 Notes 做了。以下是要改的部分。

## Fixed
- `random_state=None` 时另开 `np.random.default_rng()`,**绕过了 `core.get_rng`** ——
  与决策 24 冲突:`set_seed` 对它无效,同一段脚本重跑区间就变。改成无条件 `get_rng(random_state)`(决策 46)。
- `metrics["statistic_name"]` 用 `hasattr(statistic, "__name__")` 判不出 lambda:
  lambda 的 `__name__` 恒为 `"<lambda>"`,于是 `else "custom"` 是**死代码**,
  实测自定义统计量拿到的是 `"<lambda>"`。改成 `getattr(f, "__name__", None)` + 判 `"<lambda>"`(易错点 17)。
- docstring 补齐 `Parameters` / `Returns` / `Raises`(§4.7),并如实写明 `method="bca"` **尚未实现**
  (此前 docstring 承诺支持、实现却抛 `NotImplementedError`)。
- ruff 两条:I001(导入块拆成三行且用了绝对导入)、F541(`f"暂不支持bca"` 无占位符)。

## Tests
- **摘掉 3 条 `skip`**:等 `bootstrap_ci` 的那 3 条一直挂着,实现写完照样全绿 —— 易错点 9 复发,
  已在 `personal-pitfalls.md` 把该条 `复发` 记到 1 并注明本次经过。
- 新增 6 条:`estimate` 等于原样本统计量、`confidence` 单调性、五种容器一致、
  自定义 lambda 走回退分支、**不给种子时跟随 `set_seed`**(钉住上面那个 rng 修复)、非法/未实现 method。
- 写测试时踩到一个真性质:极差的重采样值**永远 ≤ 原样本极差**,`high` 会正好等于 `estimate`,
  断言不能写 `estimate < high`。这条已写进卡片 §5。
- 全量 **177 条:143 passed / 34 skipped / 0 xfail**;ruff 0,pyright 0;`examples/01` 跑通。

## Docs
- `docs/algorithms/bootstrap_ci.md`:**第 2 张 S 档卡片**(模板九节 + §10 待拍板)。
  `docs/algorithms/README.md` 进度表打勾。
- `docs/dev/personal-pitfalls.md`:新增第 17 条(`hasattr(f, "__name__")` 挡不住 lambda,`else` 分支即死代码),
  索引补到 17;第 9 条记一次复发。
- `STATUS.md`:E 节记 `bootstrap_ci`(L0 10/12 → 剩余 `monte_carlo` `save_figure`),决策 46,
  「下一步」第 1 条改写、新增第 8 条列 5 个待拍板口径。

## 待用户拍板(Codex 一律未动,按决策 39)
- `x` 里的 NaN 静默传进统计量 → `nan` 区间(`confidence_interval` / `t_test` 都是先丢 NaN 的,口径不齐)
- `n_resamples <= 0` 现在抛 `IndexError: index -1 is out of bounds`(应在入口给可读 ValueError)
- `confidence` 越界交给 numpy 抛 `Percentiles must be in the range [0, 100]`
- `method="bca"`:补实现(≈25 行,S 档)还是从 docstring 删掉
- `bootstrap_statistic` 仍是 `NotImplementedError`,而 `bootstrap_ci` 把那段重采样循环内联了 ——
  建议抽出去让 `bootstrap_ci` 调它(易错点 16 的变体)
2026-10-03 19:59:38 +08:00
guzhujushi b70a96dd0c refactor(core): 统一入口 as_array —— 六处手抄的入参处理收成一处(决策 44/45)
## 新增
- `core.validate.as_array(x, *, numeric_only=True, dtype=float, ndim=None) -> (arr, kind)`:
  一次做完"类型白名单 + 转 ndarray + 报告原来是什么容器"。`kind` 是 `"frame"` / `"series"` / `"array"`。
- **还原不代做**(用户拍的板):`describe` / `correlation_*` / `confidence_interval` / `t_test` 的
  输出天生与输入不同形,统一还原只会重演易错点 14;所以只报告 `kind`,调用方自己手写三行。
- `core/__init__.py` 导出 `as_array`;`check_array`(L1 未实现)的 docstring 注明实现时复用它。
- 入口三条硬规矩:**默认绝不摊平**(要摊平显式 `ndim=1`);**不删缺失值**;
  `numeric_only` 按"输出是否与输入同形"选,不按喜好选。

## 去重
- `normalize` / `standardize` / `log_transform` / `zscore` / `confidence_interval` / `t_test`
  六处手抄的 `isinstance(x, pd.DataFrame)` / `select_dtypes` / `np.asarray` 全部删掉,各剩 1 行入口。
- `data/transform.py` 的 `_NUMERIC_TYPES` 白名单连同 3 份副本一起删除 ——
  这类判断原来散在 **5 个文件 31 处**。
- `transform` 的三个函数走 `numeric_only=False`:它们的输出必须与输入同形,跳过非数值列会少列。

## 修掉
- `zscore` / `normalize` / `standardize` / `log_transform` 的 Series 分支**补回 `name`** ——
  此前 `pd.Series(result, index=...)` 会把 `name` 丢掉,管道里 `concat` 会变成无名列。
- "容器对、内容转不成 float"的错误类型,由 numpy 的 `ValueError` 统一成 `TypeError`(决策 45,**待确认**)。

## 测试与文档
- `tests/test_core.py` 新增 `TestAsArray` 8 条:五种容器的 kind、默认只取数值列、
  `numeric_only=False` 遇文本报错、默认不摊平、`ndim=2` 只校验不重塑、白名单外一律 TypeError、
  **保留 NaN**、`dtype=None` 保持原 dtype;`tests/test_stats.py` 新增 1 条(Series 保住 index/name)。
- 全量 **171 条:134 passed / 37 skipped / 0 xfail**;ruff 0,pyright 0;`examples/01` 跑通(exit 0)。
- `docs/02-architecture.md` 新增 §4.8「入口只有一个:`core.validate.as_array`」,目录树补 `as_array`;
  `docs/03-roadmap.md` 的 L1 表下补说明;`docs/dev/personal-pitfalls.md` 新增第 16 条并把索引补到 16。
- `STATUS.md`:决策 44 / 45,E 节记 as_array,「下一步」第 7 条列 3 个待拍板口径。

## 待用户拍板(Codex 一律未代选)
- `int` / `float` 标量是否继续留在白名单(现在会产出 0 维数组,`axis=0` 的算法照样炸)。
- "内容转不成 float" 该用 `TypeError`(现状)还是 `ValueError`。
- 要不要让 `as_array` 顺带返回选中的列(改三元组),省掉 `frame` 分支重写 `select_dtypes`。
2026-10-03 19:17:22 +08:00
guzhujushi f95fbd2726 fix(stats): t_test/confidence_interval 收口 —— 307/308 是存根误报,CI 的夹取才是真 bug(L1 提前 2 个)
## 用户问的「t_test 307 / 308 两行报错」
- 那两行是 `float(res.statistic)` / `float(res.pvalue)`,报错来自 scipy 的 `TtestResult`:
  它和 `PearsonRResult` 一样是 `_make_tuple_bunch` **运行时**拼出来的类,存根里没有这两个字段。
- 探针实测 `TtestResult(statistic=3.873, pvalue=0.0305)`,运行时取值完全正常 —— **不是真 bug**。
- 按决策 40 的路子补 `_statistic(result: Any) -> float`(与 `_pvalue` 成对),一行 `# type: ignore` 都没挂。
  详情见 `docs/04-environment.md` 坑 10(已扩写为 pvalue / statistic 两条)。

## 顺手查出来的真 bug:`confidence_interval`
- 原码 `return (float(min(0.0, lower)), float(max(upper, 1.0)))`:
  ① `min` / `max` 的**参数写反**,下界被钉死成 `0.0`;
  ② **无条件**把三种 method 的区间都夹到 `[0, 1]`。
- 实测 `confidence_interval(rng.normal(170, 8, 50), method="t")` 下界是 `0.0`(正确 `171.0 ± 2.1`);
  `confidence=1.5` 被夹成 `(0.0, nan)` 而不报错。
- 现在:**只有 `proportion` 夹**(决策 42),`normal` / `t` 返回 `mean ± z|t·se` 不夹;
  `method` 非法抛 `ValueError`(§4.7)、`confidence` 走 `(0, 1)` 开区间、样本量 < 2 抛 `ValueError`。
- 入参放宽到 `list` / `tuple` / `ndarray` / `Series` / `DataFrame`:
  docstring 的 Examples 传 list、L0 用例传 ndarray,原来**两条都直接 `TypeError`**。

## t_test
- docstring 补齐 `Parameters` / `Returns` / `Raises` / `Notes`(§4.7);
  `_clean_data` 改用 `isinstance(data, pd.DataFrame)` 收窄(坑 8 的 `cast`);
  `alpha` 开区间校验;`df` 按 Welch–Satterthwaite / pooled / paired 分别给。
- **4 个口径未代选**(决策 39),已记进 `STATUS.md`「下一步」第 6 条待用户拍板:
  单样本结论文案、`paired` 的 NaN 对齐、`value` 被静默忽略、`DataFrame` 摊平多列。

## 测试与文档
- `tests/test_stats.py` +10 条(36 → 46),含把原先挂 skip 的
  `test_confidence_interval_matches_scipy` 转正;对照一律用解析式(z 公式 / Welch df / t 公式)。
- 全量:**125 passed / 37 skipped / 0 xfail**,ruff 0,pyright 0。
- 个人易错点新增第 15 条,并把**停在 6 号**的索引表补齐到 15(只补索引,正文未动)。
- `docs/04-environment.md` 顺手修掉残留的 `[FENCE]` 代码围栏占位符与一处重复的 `---`。
- `STATUS.md`:L0 记 9/12;校正测试数为 162 条;新增决策 42 / 43。
2026-10-03 18:40:41 +08:00
guzhujushi 95f5266563 feat(stats): correlation_matrix 按方案 A 拆成三个函数,收口返回契约(L0 9/12)
用户 2026-10-03 拍板方案 A(见决策 41),由 Codex 落地。

拆成三个函数:
- correlation_matrix  -> 数值系数矩阵(对角线 1.0)
- correlation_pvalues -> 数值 p 值矩阵(对角线 NaN)
- correlation_table   -> 星标字符串表(对角线 1.000 不带星)
三者共用 _check_corr_args 守卫与成对删缺失口径;correlation_table 在最外层调另外两个。
理由: 字符串表做不了后续计算(挑高相关对、喂聚类/PCA、画热力图),反过来随时能格式化;
      一旦返回字符串,数值信息不可逆地丢失。

顺带修掉两处:
- 对角线 p 值由 0.0 改成 NaN —— 写 0 会被下游读成"极显著",而"自己和自己"没有 p 值
- p 值与 r 现在用同一批观测: 先成对 dropna() 再算,并同样尊重 min_periods。
  此前 corr 是成对删缺失、pearsonr 不删,两者样本量不同,r 与 p 对不上
- p 值只算上三角再镜像(成对变量的 p 天然对称),省一半重复计算

tests: test_correlation_matrix_diagonal_is_one 摘掉 xfail 转正;新增 8 条
  (数值性+对称性、非数值列跳过、p 值对照解析式 t = r*sqrt((n-2)/(1-r^2))、
   对角线 NaN、成对删缺失口径、min_periods、对角线无星标、表格与数值输出一致)。
  测试 105 passed / 38 skipped / 1 xfail -> 114 passed / 38 skipped / 0 xfail。

验证: pytest 114 passed / 38 skipped;ruff All checks passed;pyright 0 errors。

docs: STATUS.md(L0 9/12 + 决策 41 + 下一步);CHANGELOG;
      personal-pitfalls 追加第 14 条(把"能看的表"和"能算的表"塞进同一个返回值);
      roadmap 记录 correlation_matrix 的两个伴随函数
2026-10-03 14:53:05 +08:00
guzhujushi 3432bf3e50 fix(stats): correlation_matrix 修硬 bug,两处 pyright 报错按坑 8/10 正经修(无 ignore)
用户报的是"63 行 p 值赋值报错"。查下来:63 行运行完全正常(p 是 np.float64),
pyright 那条是 scipy 存根的缺陷。但顺着看发现了真正跑不起来的第 41 行。

correlation_matrix:
- 硬 bug: `if numeric_cols == 0` 拿 Index 和标量比 -> 得到布尔数组。
  0 列时静默不报错、1 列时碰巧能跑、>=2 列直接 ValueError 崩(而 >=2 列正是常见情况)。
  改成 len(numeric_cols) == 0,并加回归用例
- pyright 两处报错都不是运行时问题,且一处 # type: ignore 都没挂:
  - data[numeric_cols] 是坑 8 的 DataFrame | Series 重载 -> cast 收窄
  - `_, p = stats.pearsonr(...)` 解包退化成 object(scipy 的返回值是 _make_tuple_bunch
    运行时拼的类,存根里没有 pvalue)-> 新增 _pvalue(result: Any) -> float 小助手统一取,
    后面 t_test / anova / chi_square_test 可复用
- method 校验提到最前;add_stars 的 elif 链改 if;清掉 3 条 F541 与 1 条 I001,ruff 归零
- 顺带记录两处待定:对角线被标了 ***;含 NaN 时 corr 成对删失而 pearsonr 不删,r 与 p 对不上

tests:
- test_correlation_matrix_diagonal_is_one 由 skip 改成 xfail(strict=False)(决策 30):
  契约要数值矩阵、实现返回带星标字符串表,口径待拍板
- 新增 2 条:多列数值表能跑通、非法输入报错

验证: pytest 105 passed / 38 skipped / 1 xfail;ruff All checks passed;pyright 0 errors。

docs: STATUS.md(correlation_matrix 进行中 + 决策 40 + 下一步 A/B/C 选项);CHANGELOG;
      personal-pitfalls 追加第 13 条(Index == 标量 得到布尔数组);
      docs/04-environment.md 追加坑 10(scipy 检验结果的存根缺陷)
2026-10-03 14:45:10 +08:00
guzhujushi cfc1be31ca fix(stats): CV 改用 |mean|,并记录决策 37/38/39 与经验 12
coefficient_of_variation:
- 分母由 mean 改为 |mean|(用户本人改),均值为负时 CV 不再是负值
- docstring 摘要 / Returns / Notes 同步,加一条测试钉住"均值为负时 CV 仍为正"

口径拍板(2026-10-03):
- 决策 37: 非数值列的处置按输出契约分,不按包分 —— describe/zscore 丢掉(吐数值结果表),
  detect_outliers 保留列、掩码恒 False(吐与原表同形的掩码),刻意不统一
- 决策 38: CV 用 sigma/|mean|
- 决策 39: 工作约定 —— 语义/非法输入口径由用户拍板,Codex 只列选项不代选;
  ruff / STATUS.md / CHANGELOG.md / tests / docstring 这类与算法无关的工程事务 Codex 顺手补

经验(docs/dev/personal-pitfalls.md,只追加):
- 第 12 条: 三个并列的 if 让后面的静默覆盖前面的(transform.normalize 三种 method 实际只走 max)
  第 11 条(上一轮)是 descriptive 的 .loc 对齐失败静默填 NaN —— 两条的共同点是"不抛异常"

验证: pytest 103 passed / 39 skipped;ruff All checks passed;pyright 0 errors。

docs: STATUS.md 决策 37/38/39 + 下一步;CHANGELOG 记录
2026-10-03 13:26:13 +08:00
guzhujushi e9624cb3a7 feat(stats): describe 落地,修掉一个静默填 NaN 的 bug(L0 8/12)
src/stats/descriptive.py 由用户本人实现,这里做审查 + 代修。

describe:
- 真 bug:df_num.describe().T 把表转反(行=变量、列=指标),而 docstring 与
  test_describe_reports_expected_keys 定的是"行=指标、列=变量"。转置后
  .loc[指标名] = <按变量索引的 Series> 与列名零交集,pandas 不报错,直接把
  missing / missing_ratio / skewness / kurtosis 四行整行填成 NaN(静默丢数据);
  weights 分支则报 cannot set a row with mismatched columns。去掉 .T 一处修好三件事
- 补 median 行(pandas 只有 50%,测试契约定的是 median)
- 入参放宽到 list / tuple / ndarray(00_quickstart.py 传的就是 ndarray)
- weights 校验补齐:维度 / 长度 / NaN / 负值 / 和为 0(原先和为 0 时裸抛 ZeroDivisionError)
- docstring 按 4.7 补 Parameters / Returns / Raises

zscore: 修 RET505;docstring 补齐;DataFrame 取列处按坑 8 用 cast 收窄
coefficient_of_variation: 均值为 0 改抛 ValueError(取值问题不是类型问题);
  空数组提前报错(原先静默 nan + RuntimeWarning);错别字与文案修正

tests: 去掉 test_stats.py 的模块级 pytestmark —— 它把 6 条测试全部跳过(含已实现的
  describe),正是决策 26 要禁的写法。改为逐条 skip 未实现的 5 条,并新增 20 条用例。

验证:pytest 102 passed / 39 skipped(原 82/40);ruff All checks passed;pyright 0 errors。
examples/01_clean_and_describe.py 填掉 TODO(L0),实测 exit 0。

docs: STATUS.md L0 8/12 + 决策 35/36 + 三处待拍板口径;CHANGELOG 记录;
      personal-pitfalls 追加第 11 条(pandas 对不齐就静默填 NaN)
2026-10-03 13:17:58 +08:00
guzhujushi 4552aee41c fix(data): 修掉 transform.py 的 3 处真 bug,并收口 4 个口径
不是编辑器误报:pyright CLI 与 pytest 都能复现(修前 7 failed / 75 passed),
所以一行 # type: ignore 都没加。

- 输入白名单漏了 pd.DataFrame -> standardize/log_transform 直接 TypeError,
  函数体里的 is_df 分支全是死代码;统一成模块级 _NUMERIC_TYPES 并前置到 np.asarray 之前
- normalize 三个 method 分支互相覆盖(minmax/vector 的结果被末尾无条件的 x/max 冲掉,
  实际三种 method 都只走 max),改成 if/elif/else
- one_hot_encode 按 data.columns 重包一层 DataFrame -> 哑变量列名对不上,编码列整列丢失,
  改为直接返回 get_dummies 的结果
- log_transform 的 offset 守卫写反(offset > 0 就抛错,恰好拒掉唯一合法用例),
  改为校验定义域前提 min(x) + offset > 0;offset=0 对全正数据合法
- 四个函数 docstring 按 docs/02-architecture.md 4.7 补齐 Parameters/Returns/Raises
- ruff 4 条 RET505 清掉;pyright 2 errors -> 0;测试 82 passed / 40 skipped

docs: STATUS.md L0 进度 7/12 + 决策 35;CHANGELOG 记录本轮回归与修复;
      personal-pitfalls 追加第 10 条(把真报错当成编辑器误报)
2026-10-02 21:48:27 +08:00
guzhujushi 6eab0babde feat(data): transform.py 四个函数落地;补 18 条测试、清 5 条 ruff
实现(用户本人写):
- normalize(minmax / vector / max,三种都按列)+ feature_range 校验 + 常量列不出 nan
- standardize(按列,ddof 真的传下去)+ 类型守卫
- log_transform(offset=None 时自动取 1 - min(x),保证 x + offset >= 1)+ 类型守卫
- one_hot_encode(自动识别对象列 / 指定列 / drop_first)
- data/ 下的 L0 至此结清(L0 7/12)

Codex 的轻度修复(不改接口与语义):
- 清掉 5 条 ruff:RET503 / F541 / RET504 / RET505 / SIM108
- supported_method 与 feature_range 的校验提到 np.asarray 之前(守卫集中开头)
- 修掉说反的报错文案(条件是要求下界 < 上界,原文写"上界必须小于下界")
- one_hot_encode 自动识别时显式加上 str / string:只写 object 在 pandas 3 上是弃用路径,
  每次调用发 Pandas4Warning,未来会变成"一列都选不中"的静默错误(详见坑 9)

测试:
- 新写 18 条(transform 段),其中两条是把原先挂着的 skip 转为实跑 ——
  normalize / standardize 的实现此前一条测试都没跑到(详见个人易错点 9)
- 82 passed / 40 skipped / 0 xfailed;ruff 0;pyright 0

文档:
- STATUS.md:E 节 + 验证表(122 条用例分布)+ 下一步重写(transform 待定的 4 个口径)+ 决策 34
- docs/04-environment.md:新增坑 9(pandas 3 的 str dtype 与 select_dtypes 弃用路径,含实测对照表)
- docs/dev/personal-pitfalls.md:只追加第 9 条(实现写完但测试还挂 skip,绿灯是假的)
- CHANGELOG.md:本轮变更 + 记录 transform 待定的 4 个口径
2026-10-02 20:43:03 +08:00
guzhujushi 5749e50e83 chore: 补 S 档卡片 / examples;clean.py 收尾,xfail 清零
实现收尾(Codex 代写,用户授权):
- impute_missing 的 mode 分支改为返回新表(原先就地改调用方的 DataFrame,另外四支都返回新对象)
- detect_outliers 补入参类型校验(§4.7:非法类型抛 TypeError,不静默兜底):
  接受 DataFrame / Series / list / tuple / ndarray;tuple 同时补进 is_ld_array;
  dict 不再被接受(以前靠 pd.DataFrame() 碰巧能过,见决策 33)

测试:
- 摘掉最后一条 xfail(mode 就地改)并加强断言
- 新增 3 条:非法类型抛 TypeError、元组按一维数组型处理、全 NaN 列与单元素列不抛异常
- 64 passed / 42 skipped / 0 xfailed(xfail 清零,7 条契约全部转正);ruff 0;pyright 0

文档:
- 新增 docs/algorithms/detect_outliers.md —— 第一张 S 档函数卡片(roadmap 要求约 15 张)
  除模板九节外多记三条判断:为什么不返回 Result、非数值列恒 False 的两条备选路各错在哪、
  ddof 口径与 stats.zscore 不一致(前者 1、后者 0,小样本差一成)
- 新增 examples/01_clean_and_describe.py —— 六个 data 函数的最小用法,实测跑通(exit 0)
- STATUS.md:E 节收尾、验证结果表、下一步重写(transform/standardize 的待定项)、决策 33
- CHANGELOG.md:把 [Unreleased] 里重复 7 次的 Added/Changed 小节合并成 Added/Changed/Notes;
  并修掉上轮遗留的缺陷 —— pyright 那条 bullet 的开头被吞掉,半句挂在 ruff 那条后面
2026-10-02 15:48:12 +08:00
guzhujushi b6dc4bc5a6 feat(data): detect_outliers 收尾(文本列恒 False)+ 补测试与文档
实现(用户本人写 + Codex 轻度收尾):
- 统计量(quantile / mean / std)与掩码写入都收窄到数值列,含文本列的赛题表不再崩
- 非数值列在掩码里恒 False(用户定的语义),docstring Notes 补上这条
- 删掉死代码 data=data.copy():下面的 to_frame()/pd.DataFrame() 本就返回新对象,
  函数全程只读 data,白复制一份;且它排在类型分派之前,会把错误入参的报错
  从 ValueError 变成 AttributeError
- 删掉那行死代码后 pyright 反而暴露 1 个真错:df[Index] 被类型存根当成 Series,
  .std() 于是被判成标量、std.replace(...) 非法。在取列子表处 cast 成 DataFrame
  按运行时真值收窄(详见 docs/04-environment.md 坑 8)

测试:
- test_detect_outliers_survives_text_columns 摘掉 xfail 并加强断言(文本列必须恒 False)
- 新增 test_detect_outliers_all_text_frame_returns_all_false,钉住"整表无数值列"边界
- 58 passed / 2 xfailed  ->  60 passed / 1 xfailed;ruff 0;pyright 0
- 仅剩 1 条 xfail:impute_missing 的 mode 分支就地改调用方的表(要选语义,留给用户)

文档:
- STATUS.md:L0 5/12、验证结果表、下一步重写、新增决策 32(非数值列恒 False)
- docs/04-environment.md:新增坑 8(pandas 存根让 df[Index] 退化成 Series,及三种无效修法)
- docs/dev/personal-pitfalls.md:只追加第 8 条(限制掩码 ≠ 限制统计量)
- CHANGELOG.md:本轮变更
2026-10-02 15:41:08 +08:00
guzhujushi 319b16b17c fix(data): clean.py 轻度修复 + 摘掉 5 条已修好的 xfail
Codex 直接改的(格式 / 死代码 / 纯 bug,不动接口与语义):
- winsorize(Series) 直接崩:select_dtypes 在 Series 上不存在,Series 分支提到最前并加数值 dtype 守卫
- winsorize 的分位数改为只在数值列上算,含文本列的赛题表不再崩(与已有 numeric_cols 判断对齐)
- 删掉 detect_outliers 里已成空操作的 df = pd.DataFrame(data),去掉 df 别名
- clean_missing 的嵌套 if 合并(ruff SIM102),清掉 W293 行尾空白与函数内连续空行

测试:
- 5 条 xfail 经 XPASS 信号确认已修好,摘掉 mark 转正式用例
  (detect_outliers 4 条 + winsorize 混合类型 1 条)
- 53 passed / 7 xfailed  ->  58 passed / 2 xfailed
- 剩 2 条 xfail 保留,且不是漏改而是设计没定:
  detect_outliers 遇文本列返回什么、impute_missing 的 mode 分支要不要就地改调用方的表
- ruff: 1 error -> All checks passed;pyright: 0 errors 不变

文档:
- STATUS.md:L0 进度 4/12、验证结果表、下一步重写、新增决策 31
  (Codex 可直接改 src 的范围:纯 bug / 格式 / 死代码;要选语义的留给用户)
- CHANGELOG.md:本轮变更,并修掉 [Unreleased] 里被重复追加撑坏的一句
- docs/dev/personal-pitfalls.md:只追加第 7 条(守卫子句要排在类型分派之后)
2026-10-02 15:18:15 +08:00
guzhujushi c2595b6d07 feat(data): clean.py 四个函数落地;补测试、docstring 与 ruff 清理
src 由用户本人写(决策 25),Codex 只做审查、补测试与文档。

实现(用户):
- clean_missing:按行/列删缺失过多的行,threshold 用 math.ceil 算 min_non_na
  (3 列、阈值 0.5 时必须是 2;写 int() 截断会漏删)
- impute_missing / detect_outliers / winsorize(原属 L1,顺手落地)

Codex 改的轻度项:
- 按 §4.7 给四个函数补 Parameters / Returns / Raises(与 io.py 同一套 numpydoc 格式)
- ruff 6 -> 1:导入分组(I001)、elif 链改 if 加末尾无条件 return(RET505/RET503)、
  去掉多余 f 前缀(F541)、删掉只会被覆盖的 mask 初始化、去掉行尾空白(W293)
- tests/test_data.py:新增 13 条 clean 行为测试 + 7 条 xfail

结果:
- pytest 53 passed / 42 skipped / 7 xfailed(此前 40 / 44 / 0)
- pyright 0 errors, 0 warnings
- ruff 剩 1 条 SIM102,就在 detect_outliers 里那段待修的代码中,删掉即全绿

已知未修(详见 STATUS.md「下一步」):
- detect_outliers 三个 bug:threshold 范围校验放错函数(iqr 分支 UnboundLocalError、
  zscore 默认 3.0 被 [0,1] 拒掉)、quantile 在含文本列的表上崩、
  is_series / is_ld_array 在 data 已转 DataFrame 之后才判断,因此永远为 False
- winsorize 同样在含文本列的表上崩
- impute_missing 的 mode 分支就地修改调用方的表
2026-10-02 15:04:13 +08:00
guzhujushi 97a2f95066 fix(env): 钉死 pyright 的分析目标,修掉"无法解析导入 numpy/pandas"
现象:编辑器 / pyright 满屏 reportMissingImports,但 .venv 与 conda 环境里
import numpy, pandas 都正常,pytest 也全过。

根因两层:

1. pyright 没配 venvPath / pythonPath 时会去 PATH 上找 python。本机 PATH 上
   唯一的 python 是 agent-tools 的 shim(Codex 运行时,Python 3.14),没有科学栈。
   pyright --verbose 显示它选了 3.14.6,Search paths 里一个 Anaconda 路径都没有。
   注意持久化的 User/Machine PATH 并没有这一项,是 Codex 进程临时注入的——所以
   "我的终端没问题"和"工具有问题"可以同时成立。
2. .venv 是 --system-site-packages 建的,三方包在 D:\anaconda\Lib\site-packages,
   而 pyright / Pylance 不认 pyvenv.cfg 的 include-system-site-packages,
   只去 .venv\Lib\site-packages 找(那里只有 pip)。
   pyright 不读 pyvenv.cfg,但**认 .pth**。

改动:
- pyproject.toml [tool.pyright]:venvPath = "." / venv = ".venv" /
  pythonVersion = "3.13",并注释了为什么必须写
- scripts/setup-venv.ps1:新增第 [2/4] 步,自动往 .venv 写
  _mcm_base_site_packages.pth 指向该机 base 的 site-packages,换机重建即可
- tests/test_data.py:3 处 isna().all() 断言外面套 bool()(它的类型是
  Series | bool,直接当条件 pyright 报"条件操作数无效");
  2 处故意传错类型的用例加 type: ignore[arg-type]

结果:pyright 17 errors -> 0 errors / 0 warnings;pytest 两套环境均
40 passed, 44 skipped;ruff All checks passed;setup-venv.ps1 通过 AST 解析
(纯 ASCII + LF)。

文档:docs/04-environment.md 新增坑 7(含 pyright --verbose 诊断口诀与
VS Code / PyCharm 的操作步骤)、STATUS 决策 29、CHANGELOG。
2026-10-02 13:36:16 +08:00
guzhujushi 6758e81c9d fix(data): 校验早于副作用,补空行;经验写入 pitfalls
save_table 的两处收尾(用户授权代改):

- 后缀校验原先藏在 if/else 分支的 else 里,排在 path.parent.mkdir(...) 之后,
  传 sub/x.txt 会先把 sub\ 建出来再抛 ValueError。改成守卫子句,集中在 mkdir 之前,
  校验段结束后才产生副作用;此时 else 分支自然消失,用回 if/else 两路分派
- __all__ 与下一个 def 之间补回第二个空行(PEP 8 / E302)

测试:
- 新增 test_save_table_validates_before_touching_disk,
  断言参数非法时 tmp_path 下一个条目都不留(ValueError 与 TypeError 两条路径)
- 40 passed / 44 skipped,两套环境一致;ruff All checks passed

文档:
- docs/dev/personal-pitfalls.md 追加条目 5(参数校验排在副作用之后)
  与条目 6(把 lint 的"没报错"当成"没问题"),索引表同步加行
- STATUS:io.py 收尾,L0 第 3 项完成;测试计数 40
- CHANGELOG:Fixed 段补两条
2026-10-01 23:26:47 +08:00
guzhujushi 19f4a15107 docs(data): 补齐 io 两个函数的 Parameters 与 Raises
按 docs/02-architecture.md §4.7,每个公开函数都要写全 Parameters 与 Raises,
格式对齐参考实现 core/random.py 的 get_rng。

- docstring 由 Codex 代写(本轮用户明确授权);函数体逻辑由用户本人改
- load_table:返回注解 Any -> pd.DataFrame,与 docstring 的承诺对齐;
  写明 FileNotFoundError / ValueError / ImportError 各自在什么情况下抛
- save_table:写明 TypeError / ValueError / ImportError;
  并把 isinstance(df, pd.DataFrame) 校验挪到 mkdir 之前
- 两套环境 pytest 均 39 passed / 44 skipped,ruff All checks passed
2026-10-01 23:22:40 +08:00
guzhujushi 49fd84a2df docs: L0 进度 3/12,记录 zstandard 残骸坑与决策 26/27
- STATUS.md:本次任务拆成「第一阶段已完成 / 第二阶段进行中」;
  L0 3/12;测试计数改为 39 passed / 44 skipped;
  下一步里写明 io.py 待修的两处(mkdir 早于校验、缺 Raises)
- docs/04-environment.md 坑 6:conda 升级留下的 *.conda_trash 残骸目录会被
  Python 当成 namespace package 导入成功(__file__ 为 None),
  pandas 3.0 因此抛 ImportError: Can't determine version for zstandard
  直接打断 pd.read_csv;含排查方法与「改名禁用」的修法
- docs/dev/personal-pitfalls.md:条目 3(注解与 docstring 打架)复发 +1
- 决策 26:未实现的算法逐条 skip,禁用模块级 pytestmark
- 决策 27:zstandard 残骸改名禁用,不删不重装
- CHANGELOG:补 load_table/save_table 与 Fixed 段
2026-10-01 23:11:48 +08:00
guzhujushi b6456d97d8 feat(data): 实现 load_table 与 save_table
由用户本人实现(决策 25),Codex 只做审查与补测试。

- 按扩展名分派 .csv / .xlsx / .xls
- .csv 默认编码探测 utf-8 -> utf-8-sig -> gbk -> gb18030;
  显式传 encoding= 时不再探测
- na_values 默认吃下空串 / NA / N/A / #N/A / null / -
- save_table 自动建父目录,返回实际写入的 Path
2026-10-01 23:11:47 +08:00
guzhujushi 3cdbba4cfb test(data): 拆掉模块级 skip,给 load_table/save_table 补 8 条行为测试
原来 tests/test_data.py 顶上一条 pytestmark 把整个文件跳过了,
连已经写好的 load_table 也一起跳——等于零覆盖。

- 改成逐条 @pytest.mark.skip,只标 5 个未实现的算法
- load_table 补:UTF-8 不被误读成 GBK、NA/-/#N/A 归 NaN、
  显式 encoding 优先、未知后缀抛 ValueError、缺文件抛带路径的报错
- save_table 补:往返读写返回 Path、非 DataFrame 抛 TypeError
- 测试数 31 passed / 45 skipped -> 39 passed / 44 skipped
2026-10-01 23:11:47 +08:00
guzhujushi 98278edd77 docs: 新增「类型约定」章节(架构 §4.7)
把"数据类型必须明确"从口头要求变成仓库规则,每个函数都适用。

- 判断顺序表:整数用 (int, np.integer)、浮点用 (float, np.floating)、
  bool 要放在 int 判断之前、数组先 np.asarray 归一化
- 硬规则:非法类型必须 raise TypeError,不许静默兜底
  (静默的后果是"种子被忽略、结果不可复现且无提示",最难查)
- 要求每个公开函数写全 Parameters(含 None 走哪条分支)与 Raises
- docs/03-roadmap.md §3.2 开发纪律加一行指针
2026-10-01 20:04:32 +08:00
guzhujushi c93810ffb1 fix(core): 补齐 get_rng 类型校验,set_seed 覆盖 numpy 旧全局
get_rng 的"共享单例生成器"设计由用户本人提出,本提交只在其上修 bug。

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

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

- __repr__:单行紧凑;data 只给类型与规模(ndarray(100000)),
  绝不整数组打印;__repr__ 永不抛异常
- summary():可直接贴进论文的 Markdown 表格,
  行序 success → params.* → metrics.* → extras.*(按插入顺序,不排序),
  float 走 .6g,竖线转义,message 以引用行附在表后
- 新增 5 个模块级私有辅助:_fmt_scalar / _fmt_cell / _fmt_mapping /
  _describe_data / _truncate
- tests/test_core.py:去掉 test_result_repr_is_readable 的 skip,
  并补 3 条行为测试(默认值与失败路径与十万级数组、Markdown 结构、幂等)
- STATUS.md:L0 进度 1/12;新增决策 21(repr 不打印 data 内容)、
  22(summary 行序不排序);CHANGELOG.md 同步
2026-10-01 18:48:43 +08:00
guzhujushi bcdbdd35a3 docs: 记录 Gitea 远端接入方式
- STATUS.md:Gitea 接入标记完成,补充远端/分支/推送通道信息;
  新增决策 19(SSH 走 Tailscale 内网)与 20(首次推送 force-with-lease 的取舍)
- docs/05-git-workflow.md:第三节改写为实际拓扑(公网 22 被过滤、
  走 Tailscale + ~/.ssh/config 域名别名),补自检、日常推送、换机步骤,
  并保留仓库归属注意、补充可见性与协作者说明;
  回滚一节记录被覆盖提交的恢复命令
2026-10-01 17:56:12 +08:00
guzhujushi 96080b9dcc docs: 保留 Gitea 初始 README 的手写说明
远端 Initial commit 的 README 里有一句手写的仓库简介("这是我参加美赛时用的
学习和准备复用的代码,有需要可以自行克隆和修改"),并入本仓库 README 顶部,
避免首次推送覆盖它时丢失这句说明。
2026-10-01 17:53:50 +08:00
guzhujushi 1aca8680fb docs: 让文档与环境/仓库真实状态一致
- STATUS.md:补 D. Git 仓库小节(init/首次提交/行尾复核);
  环境版本按实测复核更新(venv 已随 base 升到 numpy 2.4.6 等);
  遗留项改为「Gitea 远程待配置」并记录沙箱账户的文件所有权副作用;
  新增决策 17(.git 由本人账户重建,不做 safe.directory 豁免)
  与 18(仓库级 core.autocrlf=false)
- docs/05-git-workflow.md:开头改为「已初始化,待配远程」;
  新增 §4 仓库归属注意,说明 dubious ownership 的成因与正确处置
- README.md:conda 脚本说明由「clone base,离线」更正为「精准包列表」;
  补一行仓库状态
2026-10-01 17:45:54 +08:00
guzhujushi 5319589dfd 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 后端)
2026-10-01 17:43:48 +08:00
80 changed files with 13934 additions and 15 deletions

No files matched your search

+30
View File
@@ -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
+51
View File
@@ -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
+63
View File
@@ -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%/
+682
View File
@@ -0,0 +1,682 @@
# 更新日志
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
版本号参考 [语义化版本](https://semver.org/lang/zh-CN/)。
## [Unreleased]
### Added
- `viz.save_figure`:L0 最后一项落地(Codex 写)。入口校验先于副作用,支持 `png` / `pdf` / `svg` / `eps` / `ps` / `jpg` / `jpeg` / `tif` / `tiff` / `webp`;`path` 带图片后缀时自动去后缀。`tests/test_viz.py` 去掉模块级 skip 并新增 8 条保存测试。
- 仓库骨架:`src/mcm_toolkit/` 7 个包(core / data / stats / models / solve / decision / viz)
- 顶层 `__init__.py` 只导出 7 个子包 + 8 个快捷函数,`__all__` 即公开 API 清单
- `core.Result`:唯一的结果对象(替代原建议里的 5 个 Result 类)
- `docs/01-suggestion-review.md`:对 ChatGPT 建库建议的审查与精简(14 包 → 7 包,~250 函数 → ~40)
- `docs/02-architecture.md`:包边界、`Result` / RNG / `fit-predict-score` 协议、依赖分层
- `docs/03-roadmap.md`:L0/L1/L2 分级 + **永久不做清单**
- `docs/04-environment.md`:conda / venv 配置与三个已踩的坑
- `docs/dev/function-card-template.md`:S 档算法的函数卡片模板
- `.venv`(基于 Anaconda base,`--system-site-packages`)+ 可编辑安装 —— 10.5 MB,离线可用
- conda 环境 `mcm-toolkit`(`D:\anaconda\envs\mcm-toolkit`)—— Python 3.13.15,含 SALib / pymoo / pyright
- `scripts/setup-conda.ps1` / `scripts/setup-venv.ps1` / `scripts/doctor.ps1`
- `environment.yml`:需联网时的精确复现方案
- `pyproject.toml`:核心依赖只 4 个,其余走 extras
- `docs/02-architecture.md` §4.7「类型约定」:注解要写全、numpy 标量的判断写法、
非法类型必须抛 `TypeError`。配套在 `docs/03-roadmap.md` 的开发纪律里加了指针。
- `docs/dev/personal-pitfalls.md`:个人易错点清单,**只追加、不通读**
- `data.clean`:四个函数落地(**由用户本人实现**)——`clean_missing`(L0 第 4 项)、
`impute_missing` / `detect_outliers` / `winsorize`(原属 L1,顺手写了)。
- `tests/test_data.py`:新增 13 条 clean 行为测试,覆盖 `clean_missing` 的阈值语义与轴向、
`impute_missing` 的 mean/ffill/mode 与全 NaN 列、`winsorize` 的分位点与异常类型。
- `tests/test_data.py`:另加 7 条 `xfail`,把**已知但未修的契约**钉在测试报告里(见 Notes)。
- `docs/algorithms/detect_outliers.md`:**第一张 S 档函数卡片**(roadmap §3.1 要求
约 15 张,这是第 1 张)。除模板九节外,额外记了三条容易踩的判断:为什么不返回 `Result`、
非数值列恒 `False` 的两条备选路各错在哪、以及 `ddof` 口径与 `stats.zscore` 不一致。
- `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` /
`impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测跑通(exit 0);
末尾留 `TODO(L0)` 等 `stats.describe`。
- `tests/test_data.py`:`data` 的 transform 段 **18 条用例** —— `normalize` 的三种 method、
`feature_range` 区间与两条校验、常量列不出 nan、docstring 那个 Examples 的精确值、按列语义;
`standardize` 的按列与 `ddof`(用解析关系钉住:`ddof=1` 时结果的 std = `sqrt((n-1)/n)`);
`log_transform` 的定义域平移;`one_hot_encode` 的自动识别、`drop_first`、列不存在报错。
其中两条是把原先挂着的 `skip` 转为实跑(见坑 9 / 个人易错点 9)。
- `tests/test_stats.py`:新写 **20 条**用例 —— `describe` 的方向约定(行 = 指标、列 = 变量)、
`missing` / `missing_ratio` / `skewness` / `kurtosis` 四行**不能是 NaN**、非数值列自动跳过、
接受 ndarray / list / Series、加权均值与加权标准差对照手算公式、5 种非法 `weights`;
`zscore` 的手算对照 / 按列 / 常量列 / 非法类型;`coefficient_of_variation` 的手算对照、
单列 DataFrame、均值为 0 抛 `ValueError`、空数组与二维输入。
- `examples/01_clean_and_describe.py`:填掉末尾的 `TODO(L0)`,加一段 `describe` 输出(实测 exit 0)。
### Added
- `stats.correlation_pvalues` / `stats.correlation_table`:`correlation_matrix` 的伴随函数
(**方案 A**,用户 2026-10-03 拍板,见决策 41)——
`correlation_matrix` 只返回**数值**矩阵;`correlation_pvalues` 给数值 p 值矩阵、
`correlation_table` 给"系数 + 显著性星标"的论文表格。
三者共用 `_check_corr_args` 入参守卫与**成对删缺失**口径,`correlation_table` 在最外层调另外两个。
### Changed
- `core.get_rng` / `core.set_seed`:实现(L0 第 2 项)。`get_rng` 收 `int` / `np.integer` /
`Generator` / `None`,其余类型抛 `TypeError`;无参调用复用同一个会推进的共享生成器。
`set_seed` 同时覆盖 numpy 旧全局状态,让 sklearn / statsmodels 也可复现。
- `core.Result`:补上 `__repr__` 与 `summary()` 实现(L0 第 1 项落地)。
`repr` 紧凑单行且不打印 `data` 内容;`summary()` 输出可直接贴进论文的 Markdown 表格。
- `data.load_table`:实现(L0 第 3 项)。按扩展名分派 `.csv` / `.xlsx` / `.xls`;
`.csv` 默认做编码探测(utf-8 → utf-8-sig → gbk → gb18030),显式传 `encoding=` 时不再探测;
`na_values` 默认吃下空串 / `NA` / `N/A` / `#N/A` / `null` / `-`。
- `data.save_table`:实现(原属 L1,顺手落地)。按扩展名分派,父目录不存在时自动创建,
返回实际写入的 `Path`。
- `data.load_table` / `data.save_table`:docstring 补 `Parameters` / `Raises`
(`docs/02-architecture.md` §4.7 对每个公开函数的硬要求)。
- `data.load_table` 的返回注解 `Any` → `pd.DataFrame`,与 docstring 的承诺对齐。
- `data.clean`:docstring 按 `docs/02-architecture.md` §4.7 补全 Parameters / Returns / Raises
(Codex 代写,与 io.py 同一套 numpydoc 格式)。
- `data.clean`:ruff 从 6 条清到 1 条——导入分组(I001)、elif 链改 if 加末尾无条件 return
(RET505 / RET503)、去掉多余 f 前缀(F541)、删掉只会被覆盖的 mask 初始化(死代码)、
去掉行尾空白(W293)。
- `pyright` 报"无法解析导入 numpy / pandas"(17 errors)的原因:分析器会去 PATH 上找 python,
本机 PATH 上只有 Codex 运行时的 3.14 shim(无科学栈),且 pyright 不认
`pyvenv.cfg` 的 `include-system-site-packages`。
改为 `[tool.pyright]` 指定 `venvPath = "."` / `venv = ".venv"` /
`pythonVersion = "3.13"`,并让 `scripts/setup-venv.ps1` 往 `.venv` 里写一个
指向 base site-packages 的 `.pth`。pyright 17 errors → **0**。
详见 `docs/04-environment.md` 坑 7。
- `tests/test_data.py`:`df["b"].isna().all()` 的类型是 `Series | bool`,
直接当断言条件 pyright 会报"条件操作数无效",外面套 `bool()`;
两条故意传错类型的用例加 `# type: ignore[arg-type]`。
- `data.save_table` 的后缀校验排在 `path.parent.mkdir(...)` **之后**,传 `.txt` 会先留下一个
空目录再抛 `ValueError`。改为**先校验、后副作用**,并加回归测试。
- `data/io.py` 里 `__all__` 与下一个 `def` 之间只有 1 个空行(PEP 8 要 2 个)。
- `tests/test_data.py` 顶上的**模块级** `pytestmark = pytest.mark.skip(...)` 会连带跳过同文件里
已经写好的函数,导致 `load_table` 零覆盖。改为逐条 skip 未实现的算法,并补 8 条 io 测试
(编码探测、NA 归一、后缀校验、缺文件报错、往返读写、类型校验)。测试数 31 → **39 passed**。
- conda base 里 zstandard 的 `*.conda_trash` 残骸目录被 Python 当成 namespace package 导入,
使 `.venv`(= base)的 `pd.read_csv` 抛 `ImportError: Can't determine version for zstandard`。
已把残骸目录改名禁用(**可逆**,未删除),详见 `docs/04-environment.md` 坑 6。
- `data.clean` 第二轮审查(2026-10-02),Codex 直接修掉 4 处**轻度**问题:
`winsorize(Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩(Series 分支提到最前,
并加数值 dtype 守卫);`winsorize` 的 `data.quantile()` 改为只在数值列上算
(文本列不再崩,与已有的 `numeric_cols` 判断对齐);删掉 `detect_outliers` 里已成空操作的
`df = pd.DataFrame(data)`(数据在更早处就转过了,顺手去掉 `df` 这个别名);
清掉行尾空白(W293)、函数体内的连续空行,并把 `clean_missing` 的嵌套 `if` 合并
(SIM102,ruff 最后 1 条)。ruff `1 error` → **`All checks passed`**,pyright 仍 0。
- `tests/test_data.py`:5 条 `xfail` 经 XPASS 信号确认已修好,**摘掉 mark 转正式用例**
(`detect_outliers` 的 4 条 + `winsorize` 混合类型 1 条)。测试 **53 passed / 7 xfailed →
58 passed / 2 xfailed**。
- `CHANGELOG.md`:[Unreleased] 里有一句被重复追加撑坏
("…删掉它即全绿。:分析器会去 PATH 上找 python,"),已修。
- `data.detect_outliers` 收尾(2026-10-02,**由用户本人实现**):统计量(`quantile` /
`mean` / `std`)与掩码写入**都**收窄到数值列,含文本列的赛题表不再崩;
非数值列在掩码里恒 `False`(用户本人定的语义,见决策 32)。docstring `Notes` 补上这条语义。
- `data.detect_outliers` 里 `data=data.copy()` 是死代码:下面的 `to_frame()` / `pd.DataFrame()`
本来就返回新对象,函数全程也没原地改过 `data`,白白复制一份数据;而且它排在类型分派
**之前**,会把错误入参的报错从 `ValueError` 变成 `AttributeError`。已删。
- `data.detect_outliers`:删掉那行死代码后 pyright 反而报了 1 个真错 ——
`df[Index]` 被类型存根当成 `Series`,`.std()` 于是被判成标量、`std.replace(...)` 非法。
在取列子表处用 `cast(pd.DataFrame, ...)` 按运行时真值收窄。详见 `docs/04-environment.md` 坑 8。
- `tests/test_data.py`:`test_detect_outliers_survives_text_columns` 摘掉 xfail 并加强断言
(文本列必须恒 `False`);新增 `test_detect_outliers_all_text_frame_returns_all_false`
钉住"整表没有数值列"这条边界。测试 **58 passed / 2 xfailed → 60 passed / 1 xfailed**。
- `docs/dev/personal-pitfalls.md`:追加第 8 条(限制掩码 ≠ 限制统计量)。
- `data.impute_missing` 的 mode 分支改为**返回新表**(原先 `data[col] = ...` 会就地改调用方的
DataFrame,另外四支都返回新对象)。最后一条 `xfail` 随之转正,**测试里 `xfail` 清零**。
- `data.detect_outliers` 补**入参类型校验**(§4.7:非法类型抛 `TypeError`,不静默兜底):
接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`。`tuple` 同时补进 `is_ld_array`,
与 `list` 同等对待;`dict` 不再被接受(以前靠 `pd.DataFrame()` 碰巧能过,见决策 33)。
- `tests/test_data.py`:摘掉最后一条 `xfail`(mode 就地改)并加强断言;新增 3 条
(非法类型抛 `TypeError`、元组按一维数组型处理、全 NaN 列与单元素列不抛异常)。
测试 **60 passed / 1 xfailed → 64 passed / 0 xfailed**。
- `data.transform` 四个函数实现(**由用户本人写**):`normalize`(minmax / vector / max,均按列)、
`standardize`(按列,`ddof` 真的传下去)、`log_transform`(`offset=None` 时自动取 `1 - min(x)`)、
`one_hot_encode`。`data/` 下的 L0 至此结清。
- `data.transform`:Codex 清掉 5 条 ruff —— RET503(`normalize` 末尾缺无条件 `return`)、
F541(无占位符的 f-string)、RET504(多余的 `result` 中转)、RET505(`return` 后的 `elif`)、
SIM108(`log_transform` 的 if/else 改三元)。
- `data.transform.normalize`:`supported_method` 与 `feature_range` 的校验提到 `np.asarray` **之前**
(§4.7 的"守卫集中在开头");并修掉说反的报错文案 —— 条件是要求下界 < 上界,
原文却写"上界必须小于下界",且 `TypeError` 那条用了无占位符 f-string。
- `data.transform.one_hot_encode`:自动识别类别列时显式加上 `'str'` / `'string'`。
原来只写 `['object', 'category']`,在 pandas 3 上是**已弃用路径**(每次调用发 `Pandas4Warning`),
未来会变成一列都选不中的**静默错误**。详见坑 9。
- 测试计数:**64 passed → 82 passed**(新增 18 条,另两条 skip 转为实跑),skip 42 → 40。
- `data.transform`:修掉上一版引入的 **3 处真 bug**(**Codex 代修,用户授权"报错顺手修掉"**)。
症状是 7 条测试变红 + pyright 在 `transform.py:105` / `:133` 报 `reportArgumentType`。
**这不是编辑器误报** —— pyright CLI 与 `pytest` 都能复现,所以**一行 `# type: ignore` 都没加**:
- 输入白名单漏了 `pd.DataFrame`:`standardize(df)` / `log_transform(df)` 抛 `TypeError`,
函数体里的 `is_df` 分支全是**死代码**。白名单统一成模块级 `_NUMERIC_TYPES` 并移到 `np.asarray` 之前。
- `normalize` 的三个 method 分支**互相覆盖**:`minmax` / `vector` 算完的结果被末尾无条件的
`x / max` 冲掉,三种 method 实际都只走 max(`test_normalize_*` 5 条因此变红)。改成 `if / elif / else`。
- `one_hot_encode` 把 `get_dummies` 的结果按 `data.columns` 重包一层 `DataFrame`:
哑变量列名(`city_A`)与原列名(`city`)对不上 → 编码列被**整列丢掉**。改为直接返回 `get_dummies` 结果。
- `log_transform` 的 `offset` 守卫条件**写反**(`offset > 0` 就抛错,恰好把唯一合法用例拒掉),
改为校验定义域前提 `min(x) + offset > 0`;`offset=0` 对全正数据合法(见决策 35)。
- `data.transform` 四个函数的 docstring 按 `docs/02-architecture.md` §4.7 补齐
`Parameters` / `Returns` / `Raises`(Codex 代写,与 `io.py` / `clean.py` 同一套 numpydoc 格式),
并把常量列行为(决策 34)写进 `Notes`。
- `data.transform`:口径定稿后 ruff 剩的 4 条 RET505(`return` 后的 `elif`)一并清掉 ——
`All checks passed`;pyright **2 errors → 0 errors**;测试 **7 failed / 75 passed → 82 passed / 40 skipped**。
- `stats.describe`:实现落地(**由用户本人写**,Codex 审查 + 代修),并修掉一个**静默 bug** ——
`df_num.describe().T` 把表转反了(行 = 变量、列 = 指标),而 docstring 与
`test_describe_reports_expected_keys` 定的是"行 = 指标、列 = 变量"。
转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名**零交集**,pandas 不报错,
直接把这四行(`missing` / `missing_ratio` / `skewness` / `kurtosis`)**整行填成 NaN**;
`weights` 分支则报 `cannot set a row with mismatched columns`。去掉 `.T` 一处修好三件事。
另补 `median` 行(pandas 只有 `50%`,测试契约定的是 `median`)、入参放宽到
`list` / `tuple` / `ndarray`(`00_quickstart.py` 传的就是 ndarray)、
`weights` 校验补齐(维度 / 长度 / NaN / 负值 / 和为 0,原先和为 0 时裸抛 `ZeroDivisionError`)、
docstring 按 §4.7 补 `Parameters` / `Returns` / `Raises`。
- `stats.zscore`:修 RET505;docstring 按 §4.7 补齐;DataFrame 取列处按坑 8 用 `cast` 收窄
(pyright 的 `DataFrame | Series` 重载)。
- `stats.coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError`
(那是**取值**问题不是类型问题,§4.7);空数组提前抛 `ValueError`(原先静默返回 nan + RuntimeWarning);
报错文案里"异变系数"的错别字与 "N 维数组" 改为 "N 列"。
- `stats.coefficient_of_variation`:分母由 `mean` 改为 `|mean|`(**用户本人改的**,决策 38)——
均值为负时 `σ/μ` 会给出负值,容易被读成"波动更小";现在 CV 恒非负。docstring 的 `Notes` 同步,
并加一条测试钉住"均值为负时 CV 仍为正"。
- `tests/test_stats.py`:去掉**模块级** `pytestmark = pytest.mark.skip` —— 它把该文件 6 条测试
**全部**跳过(包括已经实现好的 `describe`),正是决策 26 要禁的写法:实现零覆盖而测试全绿。
改为逐条 skip 未实现的 5 条。
- 测试计数:**82 passed / 40 skipped → 103 passed / 39 skipped**(新增 21 条);
ruff 7 error → **All checks passed**;pyright **1 error → 0**。
- `stats.correlation_matrix`:实现落地(**由用户本人写**,Codex 审查 + 代修),修掉一个**硬 bug** ——
`if numeric_cols == 0:` 拿 `Index` 和标量比,得到的是布尔**数组**:0 列时静默不报错、
1 列时碰巧能跑、**≥2 列直接 `ValueError: The truth value of an array ... is ambiguous`** ——
而"≥2 个数值列"正是最常见的情况,等于整个函数不可用。改成 `len(numeric_cols) == 0`,
并加回归用例(见易错点 13)。
- `stats.correlation_matrix`:pyright 的两处报错**都不是运行时问题,也都没挂 `# type: ignore`** ——
`data[numeric_cols]` 是坑 8 的 `DataFrame | Series` 重载(`cast` 收窄);
`_, p = stats.pearsonr(...)` 解包后会退化成 `object`(scipy 的返回值是 `_make_tuple_bunch`
在运行时拼的类,存根里看不到 `pvalue`)。新增 `_pvalue(result: Any) -> float` 小助手统一取 p 值,
后面 `t_test` / `anova` / `chi_square_test` 可复用(见 `docs/04-environment.md` 坑 10、决策 40)。
- `stats.correlation_matrix`:`method` 校验提到最前(守卫集中开头);`add_stars` 的 `elif` 链改 `if`
(RET505);清掉 3 条 F541 与 1 条 I001(import 未排序)—— ruff 归零。
- `tests/test_stats.py`:`test_correlation_matrix_diagonal_is_one` 由 `skip` 改成 `xfail(strict=False)`
(决策 30)—— 契约要**数值**矩阵,实现返回带星标的字符串表,口径待拍板;
另新增 2 条(多列数值表能跑通、非法输入报错)。
- 测试计数:**103 passed / 39 skipped → 105 passed / 38 skipped / 1 xfail**。
- `stats.correlation_matrix`:按**方案 A** 拆开 —— 只返回**数值**矩阵,不再返回带星标的字符串表。
字符串表做不了后续计算(挑高相关对、喂聚类 / PCA、画热力图),而反过来随时能格式化;
一旦返回字符串,数值信息不可逆地丢失(见易错点 14)。
- `stats.correlation_pvalues`:**对角线由 `0.0` 改成 `NaN`** —— 写成 0 会被下游读成"极显著",
而"自己和自己相关"根本没有 p 值可言。
- `stats.correlation_pvalues`:p 值与 r 现在用**同一批观测** —— 先成对 `dropna()` 再算,
并同样尊重 `min_periods`(不足给 NaN)。此前 `corr` 是成对删缺失、`pearsonr` 不删,
两者基于不同的样本量,r 和 p 对不上。
- `stats.correlation_pvalues`:只算上三角再镜像到对称位置(成对变量的 p 天然对称),
省掉一半重复计算。
- `tests/test_stats.py`:`test_correlation_matrix_diagonal_is_one` 摘掉 `xfail` 转正;
新增 8 条(数值性 + 对称性、非数值列跳过、p 值对照**解析式** `t = r·sqrt((n-2)/(1-r²))`、
对角线 NaN、成对删缺失口径、`min_periods`、对角线无星标、表格与数值输出一致)。
- 测试计数:**105 passed / 38 skipped / 1 xfail → 114 passed / 38 skipped / 0 xfail**。
### Notes
- `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进
`clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、
非数值列恒 `False`(决策 32)、入参类型校验补上(决策 33)。
S 档卡片与 `examples/` 用法也已落地 —— `detect_outliers` 这条线**结清**。
- `transform.py` 的 **4 个口径已全部收口**:① 三个函数共用 `_NUMERIC_TYPES` 白名单(含 `DataFrame`);
② 全部接受 `DataFrame` 且保持"DataFrame 进 DataFrame 出";③ 显式 `offset` 按定义域校验
(`min(x) + offset > 0`,见决策 35,**待用户确认**);④ 常量列行为写进 docstring 的 `Notes`;
另外 `Parameters` / `Returns` / `Raises` 已按 §4.7 补齐。
- 新增易错点第 10 条:**把真报错当成"编辑器误报"** —— 红线先跑 CLI 与 `pytest` 再判断(`docs/dev/personal-pitfalls.md`)。
- `stats` 这条线开工了:`describe` 落地,填掉 `examples/01_clean_and_describe.py` 的 `TODO(L0)`;
`correlation_matrix` 功能上也能跑了(硬 bug 已修)。
- `correlation_matrix` 的返回契约已按**方案 A** 定案(决策 41):数值归 `correlation_matrix`、
p 值归 `correlation_pvalues`、星标表归 `correlation_table`。对角线不带星、p 与 r 同批观测两条也一并修掉。
- `correlation_matrix` 仍只收 `DataFrame`(`describe` / `zscore` / `transform` 都收 ndarray)——
要不要放开由用户决定,记在 `STATUS.md`「下一步」第 5 条。
- 三处口径已拍板(2026-10-03):`log_transform` 的 `offset` 维持现状(决策 35);
`describe` / `zscore` **默认丢掉**非数值列、与 `detect_outliers` 的"保留列"刻意不统一(决策 37);
变异系数改用 `σ/|μ|`(决策 38)。另立决策 39 明确分工:语义口径用户拍板、工程事务 Codex 顺手补。
- 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `bootstrap_ci` / `viz`,
`Result` 的用法届时并进去。
- 已知缺口:
- 除 `core.Result`、`core.get_rng`、`core.set_seed`、`data.load_table`、`data.save_table` 外,其余函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。
这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。
- 已知缺口:`SALib`(Sobol/Morris)、`pymoo`、`pyright` 已装进 conda 环境 `mcm-toolkit`,`.venv` 里没有。
### Fixed
- `stats.confidence_interval`:修掉一个**会给出错误数字**的硬 bug —— 原码返回
`(float(min(0.0, lower)), float(max(upper, 1.0)))`,`min` / `max` 的**参数写反**(下界被钉死成 `0.0`),
而且**无条件**把区间夹到 `[0, 1]`。实测 `confidence_interval(rng.normal(170, 8, 50), method="t")`
的下界是 `0.0`(正确是 `171.0 ± 2.1`);`confidence=1.5` 被夹成 `(0.0, nan)` 而不报错。
现在**只有 `proportion` 夹到 `[0, 1]`**(决策 42),`normal` / `t` 直接返回 `mean ± z|t·se`;
`method` 非法改抛 `ValueError`、`confidence` 按 `(0, 1)` 开区间校验、样本量 < 2 抛 `ValueError`。
- `stats.confidence_interval` 的入参曾只收 `DataFrame`,而 docstring 的 Examples 传 `list`、
L0 用例传 `ndarray` —— **两条都会 `TypeError`**。现放宽到 `list` / `tuple` / `ndarray` / `Series` / `DataFrame`。
### Added
- `stats.inference._statistic(result: Any) -> float` 助手,与 `_pvalue` 成对(决策 43)。
scipy 的 `TtestResult` 与 `PearsonRResult` 同源,都是 `_make_tuple_bunch` 运行时拼的类,
存根里 `.statistic` / `.pvalue` 都看不到。用户报告的 `t_test` 「307 / 308 两行报错」即此,
**不是真 bug**(探针实测 `TtestResult(statistic=3.873, pvalue=0.0305)`,运行时完全正常);
换成助手后 pyright 归零,**一行 `# type: ignore` 都没挂**。见 `docs/04-environment.md` 坑 10。
- `stats.t_test`(L1 提前落地):docstring 补齐 `Parameters` / `Returns` / `Raises` / `Notes`;
`_clean_data` 改用 `isinstance(data, pd.DataFrame)` 收窄(坑 8 的 `cast`);`alpha` 开区间校验;
`df` 按 Welch–Satterthwaite / pooled / paired 分别给。
- `tests/test_stats.py`:**+10 条(36 → 46)**,含把原先挂着 `skip` 的
`test_confidence_interval_matches_scipy` 转正。对照一律用解析式(z 公式、Welch `df` 公式、
`t = (mean − value)/(s/√n)`),不拿实现对自己。
- `docs/dev/personal-pitfalls.md`:新增第 15 条「区间被无条件夹到 `[0, 1]`,且 `min` / `max` 参数写反」;
并把**停在 6 号就没人管**的索引表补齐到 15(只补索引,正文一个字没动)。
- `docs/04-environment.md`:坑 10 扩写成 `pvalue` / `statistic` 两条;顺手修掉残留的
`[FENCE]` 代码围栏占位符与一处重复的 `---`。
### Changed
- `STATUS.md`:L0 记为 **9/12**(另 L1 的 `confidence_interval` / `t_test` 提前落地);
校正测试数为 **162 条 / 125 passed / 37 skipped**;新增决策 42 / 43;「下一步」列出
**`t_test` 待用户拍板的 4 个口径**(单样本结论文案、`paired` 的 NaN 对齐、`value` 被静默忽略、
`DataFrame` 摊平多列)—— 按决策 39,Codex 只列选项、不代选。
### Added
- `core.validate.as_array`:**所有收数值函数的统一入口**(决策 44)。
一次做完"类型白名单 + 转 `ndarray` + 报告原来是什么容器",返回 `(arr, kind)`,
`kind` 是 `"frame"` / `"series"` / `"array"`。**还原不代做** —— 调用方按 `kind` 手写,
因为 `describe` / `correlation_*` / `confidence_interval` / `t_test` 的输出天生与输入不同形。
入口三条硬规矩:`numeric_only` 按"输出是否与输入同形"选、**默认绝不摊平**(要摊平显式 `ndim=1`)、
**不删缺失值**(删缺失是各函数的语义)。见 `docs/02-architecture.md` §4.8。
### Changed
- **入口去重**:`normalize` / `standardize` / `log_transform` / `zscore` / `confidence_interval` /
`t_test` 六处各自手抄的 `isinstance(x, pd.DataFrame)` / `select_dtypes` / `np.asarray` 全部删掉,
各只剩 1 行 `as_array(...)`;`data/transform.py` 的 `_NUMERIC_TYPES` 白名单连同 3 份副本一起删除。
原来这类判断散在 **5 个文件 31 处**。
- `data.transform` 的 `normalize` / `standardize` / `log_transform` 走 `numeric_only=False`:
它们的输出必须与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。
- `zscore` / `normalize` / `standardize` / `log_transform` 的 Series 分支**补回 `name`** ——
此前 `pd.Series(result, index=...)` 会把 `name` 丢掉,管道里 `concat` 会变成无名列。
- 测试:`tests/test_core.py` 新增 `TestAsArray` **8 条**(五种容器的 `kind`、只取数值列、
`numeric_only=False` 遇文本报错、默认不摊平、`ndim=2` 只校验不重塑、白名单外一律 `TypeError`、
**保留 NaN**、`dtype=None` 保持原 dtype);`tests/test_stats.py` 新增 1 条(`zscore` 保住 Series 的
`index` 与 `name`)。全量 **171 条 / 134 passed / 37 skipped**。
- `docs/02-architecture.md`:新增 §4.8「入口只有一个:`core.validate.as_array`」;
目录树里 `validate.py` 补上 `as_array`。
- `docs/dev/personal-pitfalls.md`:新增第 16 条「同一段入口转换抄了 N 遍,还当它是约定」,索引补齐到 16。
### Fixed
- `normalize(含文本列的表)` 等"容器对、内容转不成 float"的情况,错误类型由 numpy 的 `ValueError`
统一收成 `TypeError`(决策 45,**Codex 代选、待用户确认**)—— 与 `t_test` / `confidence_interval`
已经写进 docstring 的口径一致。
### Added
- `stats.bootstrap_ci`(**L0 第 10 项,S 档**,实现由用户本人写):百分位法 bootstrap 置信区间,
`statistic` 可以是任意 `f(sample) -> float`(默认 `np.mean`)。返回 `Result`,
`params = {estimate, low, high, confidence, n_resamples}`、`metrics.statistic_name`。
重采样**按块处理**(`batch_size=1000`),不落地 `B × n` 整矩阵。
- `docs/algorithms/bootstrap_ci.md`:**第 2 张 S 档函数卡片**。除模板九节外,记了一条非平凡性质 ——
**统计量有上界时百分位区间会塌到上界**(极差的重采样值永远 ≤ 原样本极差,实测 `high == estimate`),
以及 5 个待拍板口径。`docs/algorithms/README.md` 的进度表同步打勾。
### Fixed
- `bootstrap_ci` 的 `random_state=None` 分支原来另开 `np.random.default_rng()`,**绕过了 `core.get_rng`** ——
与决策 24 冲突:`set_seed` 对它无效、区间不可复现。改成无条件 `get_rng(random_state)`(决策 46)。
- `bootstrap_ci` 的 `metrics["statistic_name"]`:`hasattr(statistic, "__name__")` 判不出 lambda ——
lambda 的 `__name__` 恒为 `"<lambda>"`,于是 `else "custom"` 是**死代码**,自定义统计量拿不到 `"custom"`。
改成 `getattr(f, "__name__", None)` 再判 `name in (None, "<lambda>")`(易错点 17)。
- `bootstrap_ci` 的 docstring 补齐 `Parameters` / `Returns` / `Raises`(§4.7),并如实写明
`method="bca"` **尚未实现**(此前 docstring 承诺支持、实现却抛 `NotImplementedError`)。
### Changed
- `tests/test_stats.py`:**摘掉 3 条 `skip`**(等 `bootstrap_ci` 的那 3 条),并新增 6 条断言 ——
`estimate` 等于原样本统计量、`confidence` 单调性、五种容器结果一致、自定义 lambda 走回退分支、
**不给种子时跟随 `set_seed`**、非法/未实现 `method`。bootstrap 段共 9 条。
全量 **177 条:143 passed / 34 skipped**;ruff 0,pyright 0。
- `docs/dev/personal-pitfalls.md`:**第 9 条「实现写完了测试还挂着 skip」复发 +1**(这次是 `bootstrap_ci` ——
实现写完、3 条测试仍挂 skip、全量照样全绿);新增第 17 条「`hasattr(f, "__name__")` 挡不住 lambda,
`else` 分支是死代码」;索引补到 17。
- `STATUS.md`:E 节记 `bootstrap_ci`(L0 10/12),决策 46,「下一步」改写并新增第 8 条列 5 个待拍板口径。
### Changed
- `tests/test_solve.py`:模块级 `pytestmark = pytest.mark.skip(...)` 拆成 **9 条逐条**
`@pytest.mark.skip(reason=_NOT_IMPLEMENTED)` —— 模块级 skip 会把整个文件吞掉(决策 26、易错点 9)。
用例数与跳过数不变(177 收集 / 143 passed / 34 skipped),仅粒度变细。
- `STATUS.md`:新增**分工硬规矩(决策 47)** —— 算法一律由用户本人写(即便用户改口也不代写)、
工程事务由 Codex 写;L0 计数 9/12 → **10/12**;「下一步」新增「⭐ 明天从这里开始」区块
(`monte_carlo` 的契约 / 向量化要求 / 三条测试建议 / 卡片路径 / 一个待拍板口径);
验证结果里的 skip 说明由 37 条订正为 **34 条**。
### Added
- `solve.monte_carlo` 落地(**L0 第 11 项,S 档**,实现由用户本人写):向量化"采样 → 仿真 → 汇总",
返回 `Result`(8 个 metrics:mean / std / median / p05 / p95 / ci_low / ci_high),随机数无条件走 `get_rng`。
配套 `tests/test_solve.py` **10 条**测试(含"`simulator` 只被调用一次"这条可执行的向量化断言)。
### Fixed
- `monte_carlo` 的**布尔结果**此前让 `np.std` 在 numpy 2 里抛 `numpy boolean subtract ... is not supported`,
整个函数判失败 —— 而"事件是否发生"是 MC 最常见的用法。入口改为 `np.asarray(..., dtype=float)`(布尔按 0/1 统计);
字符串结果也从"ufunc isnan 不支持"变成可读的 `could not convert string to float`。由行为探针挖出。
- `monte_carlo` 的 docstring `Examples` 跑不通:原采样器返回标量,实测 `success=False`
("simulator 返回的结果数量(1)必须与模拟次数 n(1000)一致");改成向量化写法。
- `monte_carlo` 的 ruff 三条(I001 合并同源 import、F541 空 f-string ×2)、`success= True` 的空格、
失败文案"重采样" → "模拟"(从 `bootstrap_ci` 抄过来忘改)。
### Changed
- `monte_carlo` 按 §4.7 收严 `n`:`np.int64` 不再被误拒(注解放宽为 `int | np.integer`);
`n=True` / 浮点 `n` 抛 `TypeError`;`n<=0` 抛 `ValueError`。
- 用户第二轮改的三处口径(决策 48):批量大小改由实现传 `sampler(rng, size=n)`;结果含 NaN 判 `success=False`;
`np.std` 显式 `ddof=0`。docstring 同步改写(`sampler` 契约、`Raises`、`Notes` 的 NaN 口径)。
- `STATUS.md`:L0 10/12 → **11/12**,新增决策 48;验证计数更新为 **187 收集 / 153 passed / 34 skipped**。
### Changed
- 工作约定收窄(**决策 49**):**入口没收紧 / 少考虑情况 / 不符合简单原则**这三类改动交回用户本人写,
Codex 只报告 + 列选项(判据:这处改动要不要"想一下才对")。决策 47 里"入口 / 校验重构归 Codex"的部分作废。
- `docs/dev/personal-pitfalls.md`:新增 **第 18 / 19 / 20 条**(入口校验没照 §4.7 清单逐条过 / 只想着主路径那种值 /
"不简单"的信号是同一个信息要写两遍),第 1 条(类型判断只写 `int`)**复发 +1**,索引补到 20。
- `STATUS.md`:新增决策 49 与两个待拍板小口径(`random_state` 注解是否全项目对齐、`std` 的 `ddof` 是否统一)。
### Added
- `solve.monte_carlo_probability` 落地(**L1,S 档**,实现由用户本人写):向量化 `event(batch) -> bool 数组`,
返回 `Result`(`probability` / `ci_low` / `ci_high` / `std_error`),区间是 Wald 区间并夹在 `[0, 1]`(沿用决策 42 口径)。
`tests/test_solve.py` 新增 5 条 + **摘掉 2 条 skip**(实现已写完却还挂着,易错点 9),`solve` 段共 24 条。
### Fixed
- `monte_carlo_probability` 的 docstring `Examples` 跑不通:原采样器返回标量,实测 `success=False`
("event 返回的结果数量(1)必须与模拟次数 n(2000)一致");改成批量采样写法并实测通过。
- `monte_carlo_probability` 的 ruff 六条:SIM300(`0.0 >= confidence` 写成 Yoda 条件)、W291 ×2、W293 ×2、F541。
- 该函数 NaN 提示里的 "simulator" 改成 "event"(从 `monte_carlo` 抄来没改);`metrics` 缩进与 `{"n":n}` 空格。
### Changed
- `monte_carlo_probability` 的 docstring 补齐 `Parameters` / `Raises`(原文只有 Returns / Notes / Examples),
并把"Wald 区间 + 夹到 [0,1]"与"`p <= 1e-3` 判失败"两条现状如实写进 Returns / Notes。
- `STATUS.md`:L1 清单加入 `monte_carlo_probability`;验证计数更新为 **192 收集 / 160 passed / 32 skipped**;
「下一步」新增该函数的 **6 条待办口径**(全部留给用户,Codex 未动)。
### Fixed
- **`monte_carlo_probability` 的 `z_score^2` 是异或不是乘方**(Python `^` 语义):`np.float64 ^ 2` 直接抛
`TypeError: ufunc 'bitwise_xor' not supported`,被 `except Exception` 吞成 `success=False` —— 函数实际上**整个不可用**,
6 条测试全红才暴露。改成 `z_score**2`。新增经验第 21 条。
- `monte_carlo_probability` 的 message 在没有警告时留了个尾逗号;`{"confidence":confidence}` 补空格。
### Changed
- 用户第二轮改定(**决策 50**):区间由 **Wald 换成 Wilson score**(实测 p=1、n=1000 从退化的 `(1.0, 1.0)`
变成 `(0.99617, 1.0)`;p=0 从"判失败"变成 `(0.0, 0.00383)`);小概率不再判失败,改为 message 里的警告;
`confidence` 记进 `Result.params`;`callable` 入口校验(现在抛 `TypeError`);`float()` 统一 `ci_*` 类型;
`n` 注解放宽为 `int | np.integer`。
- `tests/test_solve.py`:新增 1 条对照测试(区间必须等于 `statsmodels.proportion_confint(method="wilson")`),
共 25 条;全量 **193 收集 / 161 passed / 32 skipped / 0 xfail**。
- `docs/dev/personal-pitfalls.md`:新增第 **21** 条(公式里的 `^` 不是乘方,且被 `except` 吞成 `success=False`),索引补到 21。
- docstring 的 "Wald 区间" 同步改为 "Wilson score 区间"。
### Added
- `solve.monte_carlo_integral` 落地(**L1,S 档**,实现由用户本人写):`bounds` → 自造 `(n, d)` 均匀样本 → 逐点求值 →
`mean(func(X) × 体积)`;内部自造 `sampler` / `simulator` 再复用 `monte_carlo` —— "造批 → 一次算 → 汇总"的第二次复用。
`params` 额外记 `bounds` / `dimension`;配套 `tests/test_solve.py` 新增 4 条 + 摘 1 条 skip。
### Fixed
- **`monte_carlo_integral` 的体积符号写反**(真 bug,按决策 31 修):写成 `wights = lows - highs`,奇数维体积为负 ——
1D `x²` 于 `[0,1]` 实测 **-0.3324**(应 +1/3)、3D `x` 得 **-0.5005**(应 +1/2);**2D `x·y` 却得 +0.2499 "看着对"**
(两个负号相乘抵消,把 bug 藏住)。改成 `widths = highs - lows` 后复测 **0.3324 / 0.5005 / 1.9971(`[0,2]`)** 全部正确。
- `monte_carlo_integral` 的 docstring `Examples` 跑不通:断言 `abs(r.data - 1 / 3) < 0.01`,而 `data` 是 n 个样本的贡献数组,
`bool(数组)` 会抛 `ValueError: truth value ... is ambiguous`;改成断言 `metrics["mean"]`。
- 格式:`simulate.py` 里 `def random_walk` 前只留了 1 个空行;`tests/test_solve.py` 有 3 处留了 3 个空行。
### Changed
- `monte_carlo_integral` 的 docstring 补齐 `func` / `n` / `random_state` 三节 `Parameters` 与 `Raises`(原文只有 `bounds` +
Notes + Examples);`Returns` 写清"**`metrics["mean"]` 才是积分估计值**,`data` 是 n 个样本各自的贡献"(**决策 51**)。
- `tests/test_solve.py`:摘掉 `test_monte_carlo_integral_converges_to_analytic_value` 的 skip,新增 3 条"抓符号"用例
(3D 奇数维、`[0,2]` 宽度、2D + `name`/`dimension`/`bounds` 三个 params)与 1 条非法 `bounds` 抛 `ValueError`;
`solve` 段 25 → **30** 条,全量 **197 收集 / 166 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
- `STATUS.md`:L1 清单加入 `monte_carlo_integral`;新增**决策 51** 与「`monte_carlo_integral` 的 3 条待办」;
「下一步」改成从 `random_walk` 接着写;验证计数更新为 166 / 31;`monte_carlo_probability` 的 6 条待办收窄到 1 条。
- `docs/dev/personal-pitfalls.md`:新增第 **22 / 23** 条(失败结果的 `message` 被包装层无条件覆盖;体积/宽度也会减反、
维度奇偶会把符号错误藏住),第 3 条(注解与 docstring 不一致)**复发 +1**,索引补到 23。
- `tmp/type_demo.py`(gitignored):用 pyright 证明 `Callable[..., bool]` 会**拒绝**一个注解良好的向量化 `event`
(`ndarray` 与 `bool` 不兼容),`Callable[..., Any]` 才收 —— 这是决策 50 那条遗留的实际代价。
### Fixed
- `monte_carlo_integral` 的 `bounds` 形状校验缺失(用户第二轮补齐):`bounds=[0, 1]` 从裸 `IndexError: too many indices`
变成可读 `ValueError`(带当前形状与"请检查是否漏了括号");`[]` / 非 `list`-`tuple` / 参差不齐的输入也一并收口。
- `monte_carlo_integral` 的失败 `message` 被包装层覆盖(用户第二轮改成 `if result.success:` / `else:` 两支):
`func` 返回标量时不再是 `success=False` + "成功"文案,而是"蒙特卡洛多重积分失败:维度 1,体积 1.0000,模拟 100 次。
蒙特卡洛模拟失败:TypeError - object of type 'float' has no len()"。
- 格式:`simulate.py` 两行"只有空格的行"(ruff W293 ×2,Codex 顺手补)。
### Changed
- `monte_carlo_probability` 的 `event` 注解 `Callable[..., bool]` → `Callable[..., Any]`(用户改,**决策 50 的遗留清零**):
pyright 实测原注解会**拒绝**一个注解良好的向量化事件函数(`ndarray` 与 `bool` 不兼容),运行期却一直收得下。
- `monte_carlo_integral` 的 `n` 注解放宽为 `int | np.integer`(与全模块对齐,易错点 1 的老口径)。
- `monte_carlo_integral` 的 docstring:`bounds` 入参说明与 `Raises` 同步到新入口校验(补"非空 `list`/`tuple`、形状 `(d, 2)`")。
- `tests/test_solve.py`:非法 `bounds` 从 1 类扩到 4 类(`[(1, 0)]` / `[0, 1]` / `[]` / 参差不齐),新增"失败 message 不许出现
'成功'"与 `np.int64` 两条;`solve` 段 30 → **32** 条,全量 **199 收集 / 168 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
- `STATUS.md`:integral 的 3 条待办标记办结、概率函数的 6 条待办全部办结;新增**决策 52** 与 2 条新口径
(`bounds` 收不收 ndarray、`if`/`else` 里那三行重复);验证计数更新为 168 / 31。
### Added
- `solve.random_walk` 落地(**L1,S 档**,实现由用户本人写):`step_sampler(rng, size=(n_paths, n_steps))` 一次抽完整批增量 →
`np.cumsum(..., axis=1)` → 首列填 `start`、其余列 `start + cumsum`;`data` 形状 `(n_paths, n_steps + 1)`。
配套 `tests/test_solve.py` **新增 6 条**(此前这个函数一条测试都没有)。
### Fixed
- **`random_walk` 的默认采样器整个不可用**(真 bug,按决策 31 修):`rng.integers(0, 2, size=size, dtype=float)`
被 numpy 拒掉(`TypeError: Unsupported dtype dtype('float64') for integers`)—— 默认路径 100% `success=False`、
`data=None`,连 docstring 的 Examples 都跑不通。改成 `rng.integers(0, 2, size=size) * 2 - 1`
(由后面的 `np.asarray(..., dtype=float)` 统一转 float);实测形状 `(5, 101)`、步长恒 ±1、同种子可复现。
- 格式:`simulate.py` 又两行"只有空格的行"(ruff W293 ×2)。
### Changed
- `monte_carlo_integral` 按用户第二轮口径收口(**决策 52**):`bounds` 现在也收 `np.ndarray`;
`name` / `params["bounds"]` / `params["dimension"]` 从 `if` / `else` 两支提到 `if` 之前,只让 `message` 分两支。
- `random_walk` 的 docstring 补全(原文只有 `step_sampler` 一节):补齐 `n_steps` / `n_paths` / `start` / `random_state`
与 `Returns` / `Raises` / `Notes`,并写清 `step_sampler` 收的是**二维** `size=(n_paths, n_steps)`。
- `tests/test_solve.py`:新增 6 条 `random_walk` 测试(形状与 `start` 列、默认步长恒 ±1、同种子可复现、
自定义高斯采样器、3 类非法入参、docstring Example 真能跑);`solve` 段 32 → **38** 条,
全量 **205 收集 / 174 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
- `STATUS.md`:新增**决策 53** 与 `random_walk` 的 4 条待办;`monte_carlo_integral` 的 2 条新口径办结 1 条
(剩"第一道 `isinstance` 分支已冗余");「下一步」推进到 `markov_simulate` / `markov_steady_state`。
### Fixed
- **`random_walk` 的失败路径全部炸成 `UnboundLocalError`**(真 bug,按决策 31 修):`call_params = {...}` 写在 `try:` 里,
而 `except` 分支要引用它 —— 实测 5 种失败输入(形状不对 / 增量含 NaN / 采样器抛异常 / `start` 非数值 / 返回 1-D 数组)
**5 个全中**,本该返回 `success=False` 的 `Result`,变成把异常抛给调用方。按原意把 `call_params` 前移到 `try` 之前,
未改任何口径;失败时照样带上 `n_steps` / `n_paths` / `start` / `random_state`。
### Changed
- `random_walk` 按用户第二轮口径改定(**决策 54**):`n_steps` / `n_paths` 拆成 `TypeError`(类型)/ `ValueError`(取值);
新增 `steps.shape != (n_paths, n_steps)` 与 `np.isnan(steps).any()` 两条校验;默认步长改 `rng.choice([-1.0, 1.0], size=size)`;
`params` 补 `random_state`;`metrics` 由"抄 params"改成 `mean_terminal` / `std_terminal`(终点位置,`ddof=0`)。
- `random_walk` 的 docstring 同步:`Returns` 写实际 metrics、`Raises` 改成"类型错 `TypeError` / 取值错 `ValueError`"、
去掉已不存在的 `start` 校验、`Notes` 记明形状与 NaN 都判失败。
- `tests/test_solve.py`:+3 条("失败也必须返回带 params 的 Result"、NaN 判失败、终点 `std ≈ √t`),
`start="a"` 改为断言 `success is False`(口径待定);`solve` 段 38 → **41** 条,
全量 **208 收集 / 177 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
- `STATUS.md`:新增**决策 54** 与 `random_walk` 的 3 条新口径;4 条待办标记办结;验证计数更新为 177 / 31。
- `docs/dev/personal-pitfalls.md`:新增第 **26** 条(`except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸)。
### Changed
- `random_walk` 收口 2 条口径(用户第二轮改动):恢复 `start` 的入口类型校验(`isinstance(start, (int, float, np.number))`);
签名注解放宽为 `n_steps: int | np.integer` / `n_paths: int | np.integer`(与 §4.7 及 `monte_carlo_integral` 的 `n` 对齐)。
- `random_walk` 的 `start` 报错文案(Codex 顺手):原文"start必须是整数"与校验本身矛盾 —— 这条校验**收 float**,
改成"start 必须是数值";类型名统一 `{type(start).__name__}`(全模块口径);`n_paths: int | np.integer= 1` 补空格。
- `random_walk` 的 docstring 同步:`start` 的说明去掉"入口不校验类型",`Raises` 补回 `start` 的 `TypeError`。
- `tests/test_solve.py`:`start="a"` 恢复断言 `TypeError`;补回 `np.int64` 的 `n_steps` / `n_paths`;
`solve` 段 41 → **42** 条,全量 **209 收集 / 178 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
- `STATUS.md`:新增**决策 55**(`n_paths == n_steps` 的"方阵歧义"留作已知边界,A 接受 / B 换契约两条路);
3 条口径办结 2 条,验证计数更新为 178 / 31。
### Changed
- `random_walk` 的 `step_sampler` 契约按**方案 B** 重定(用户拍板,**决策 55**):`size` 由 `(n_paths, n_steps)` 改为**整数**
`n_paths * n_steps`,必须返回**一维**数组,内部 `reshape(n_paths, n_steps)`(行主序)。
**动因**:转置 `(n_steps, n_paths)` 与目标 `(n_paths, n_steps)` 在 `n_paths == n_steps` 时形状相同,形状校验天生分不出来(易错点 25)。
探针实测:默认采样器 `(5, 101)` 正常;转置二维 / 方阵二维 / 长度 +1 / `(N, 1)` 四种返回一律判失败。
**关键点:不要用 `.ravel()` 救场**,那会把转置的二维摊平、歧义原样回来。红利:与 `monte_carlo` 的 `sampler`(决策 48 的 `size=n`)
契约统一,给 `monte_carlo` 写的采样器可直接复用(实测通过)。
- `random_walk` 的 docstring 同步:`step_sampler` 说明改为"整数 `size` + 一维返回 + 行主序",`Notes` 记明二维返回一律判失败。
- `tests/test_solve.py`:把"转置采样器"那条改成 `test_random_walk_rejects_2d_step_sampler`(含方阵情形),
新增 `test_random_walk_step_sampler_gets_flat_size_and_is_row_major`(验 `size == 6` 与 `cumsum` 的行主序结果);
`solve` 段 42 → **43** 条,全量 **210 收集 / 179 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
- `STATUS.md`:决策 55 改为"已选 B 并落地";`random_walk` 的 7 条待办全部办结;验证计数更新为 179 / 31。
### Added
- `solve.markov_simulate` 落地(**L1,S 档**,实现由用户本人写):`arr[current_states]` 取当前状态那一行 →
`cumsum` 得累积分布 → 用 `[0, 1)` 均匀数"落格"(`(cum_probs < r[:, None]).sum(axis=1)`)→ 写进下一列;
`data` 形状 `(n_paths, steps + 1)`、值是整数状态编号。Codex 审查结论:**算法本体正确**
(探针实测 200 步后终点频率 `[0.657, 0.343]` 对照平稳分布 `[2/3, 1/3]`、同种子可复现)。
- `tests/test_solve.py`:新增 9 条 `markov_simulate` 测试(此前 0 条)—— 形状 / 首列 / 状态取值 / 整数 dtype、
单位矩阵吸收态自锁、可复现(同种子相同、异种子不同)、终点频率收敛到 `[2/3, 1/3]`、metrics = 终点状态、
非法转移矩阵(非方阵 / 行和不为 1)、非法 `steps` / `n_paths`(0 / float / bool)、`np.int64` 可收、docstring Example 真能跑。
### Changed
- `markov_simulate` 的 docstring 从一行补到完整的 `Parameters` / `Returns` / `Raises` / `Notes` / `Examples`。
- `markov_simulate` 的工程侧清理:`kind` 全程未用 → `arr, _`;`马尔科夫` → `马尔可夫`(3 处)、
`initail` → `initial`、半角逗号 → 全角;`paths[:,0]` → `paths[:, 0]`;ruff 8 条全清(`I001` + `W291` / `W293`)。
- `STATUS.md`:新增**决策 56**(`initial` 的语义待拍板,含 A / B / C 三个选项)与 5 条待办;
验证计数更新为 **188 passed / 31 skipped(219 收集)**。
### Notes
- **`markov_simulate` 的 5 条口径留给用户**(决策 49):`initial` 语义(标量按路径数 / 数组按状态数,两条口径互相矛盾)、
转移矩阵未查非负(行和恰为 1.0 的 `[[1.5, -0.5], [0, 1]]` 会静默给出全 0)、`paths[:, 0]` 赋值在 `try` 之外
(长度不匹配时裸抛 `ValueError`)、状态越界没在入口拦、`std_terminal` 对分类状态的意义。
### Added
- `solve.markov_steady_state` 落地(**L1,S 档**,实现由用户本人写):`v` 从均匀分布出发,`v_next = v @ arr`
反复推进(行随机矩阵按行向量右乘),直到 `max|v_next - v| < tol` 或耗尽 `max_iter`;
`data` 是平稳分布向量、`metrics={"converged", "iterations"}`。Codex 审查结论:**算法本体正确** ——
3 状态与 `np.linalg.eig(P.T)` 特征值 1 的特征向量逐位一致、200×200 一致到 `1.9e-12`(6 次迭代 / 0.10s)、
与 `markov_simulate` 的终点频率互验 `[0.6702, 0.3298]` vs `[0.6667, 0.3333]`。
- `tests/test_solve.py`:摘掉 `test_markov_steady_state_two_state_chain` 的 skip,另新增 7 条 ——
与特征分解对拍、`data` 是分布(和为 1 / 非负 / `float64`)、慢混合链迭代次数 > 100、`np.int64` 的 `max_iter`、
非法转移矩阵、`max_iter=0` / `tol=0`、docstring Example 真能跑。
### Changed
- `markov_steady_state` 的 docstring 补全:`max_iter` / `tol` 两个参数、`Raises` 一节,
并写清 `tol` 是**相邻差口径**(实测慢混合链 `tol=1e-10` 的实际误差 `4.6e-9`,被 `1/(1-|λ2|)` 放大约 50 倍)。
- `markov_steady_state` 的 Notes 修正"周期链会不收敛"的说法:可约链的实情是**平稳分布不唯一**,
迭代法给出的是"离初值最近的那个"(均匀初值对双随机矩阵本身就是不动点,所以 `iterations=1` 不代表链唯一平稳)。
- `markov_steady_state` 的工程侧清理:ruff 5 条 `W293`;`mm1_metrics` 前 4 个空行 → 2;失败文案 `失败: ` → `失败:`。
- `STATUS.md`:新增**决策 57**(6 条待拍板口径)与「下一步」第六轮;验证计数更新为 **196 passed / 30 skipped(226 收集)**。
- `docs/dev/personal-pitfalls.md`:新增易错点 28(数值阈值参数的 `bool` / NaN 漏网)。
### Notes
- **`markov_steady_state` 的 6 条口径留给用户**(决策 49):转移矩阵未查非负(行和恰为 1.0 的 `[[1.5, -0.5], [0, 1]]`
会溢出成 `nan`,而 `data=[nan, nan]` 却 `success=True`,违反决策 48 的 NaN 口径;附带的 `RuntimeWarning`
在 pyproject 里被设成 error,**测试会直接报错**)、`tol=True` 被当 `tol=1` 静默放行(`isinstance(True, int)`)、
`tol=np.nan` 穿过校验白跑迭代、`converged=False` 仍 `success=True`、
类型错也抛 `ValueError`(与决策 54 分工不一致)、`params` 没记转移矩阵。
### Added
- `solve.mm1_metrics` 落地(**L1,S 档**,实现由用户本人写):闭式解 `ρ = λ/μ`、`L = ρ/(1-ρ)`、
`Lq = ρ²/(1-ρ)`、`W = 1/(μ-λ)`、`Wq = W - 1/μ`;`λ >= μ` 走"系统不稳定"分支返回 `success=False`。
Codex 审查结论:**公式全对**(探针实测 3 组参数 `(2,3)` / `(1,4)` / `(0.5,1.0)` 与闭式公式逐位一致,
`W = Wq + 1/μ` 恒等式成立)。至此 `solve` 层的 L1 清单**全部落地**。
- `markov_simulate` / `markov_steady_state` 增加**非负校验**(用户 2026-10-09 改定):含负元素的行随机矩阵
现在入口即报错,不再静默给出错结果(前者会"全落 0",后者会溢出成 `nan` 却报成功)。
- `markov_steady_state` 的 `params` 补 `transition`(`arr.tolist()`);`success=converged`
(未收敛判失败,口径 B);`tol` 拆成 `TypeError` / `ValueError` 且先判 `bool`。
- `tests/test_solve.py`:摘掉 2 条 `mm1_metrics` 的 skip,新增 4 条(含 `λ == μ` 的回归测试);本轮累计 +6 条。
### Fixed
- **`mm1_metrics` 的稳定性分界写错**(真 bug,按决策 31 修):`service_rate < arrival_rate` 与 docstring / 既有测试的
`λ >= μ` 口径不符 —— `λ == μ` 会掉进 `try`、`rho == 1.0` → `ZeroDivisionError`,被吞成 `success=False`
(结果碰巧对,但 message 成了"计算失败:ZeroDivisionError - float division by zero")。改成 `<=` 并加回归测试;
**两个分支的可观测输出完全相同(`success=False` / `metrics={}` / `data=None`),故未动任何口径**。
- **三处报错文案的参数名抄错**:`markov_steady_state` 的 `max_iter <= 0` 写成"tol 必须是正数";
`mm1_metrics` 的 `service_rate` 两条报错都写成 "tol"。顺带把半角逗号统一成全角。
### Changed
- `mm1_metrics` 的 docstring 补 `Raises` 与 `data` 说明;`call_params = {{...}}` 补空格;
ruff 6 条(5 处 `W293` + `F541` f-string 无占位符 + `W292` 文件末尾无换行)。
- `markov_steady_state` 的 `try` 前 3 个空行 → 1;`异常: ` → `异常:`。
- `STATUS.md`:决策 56 / 57 更新办结项,新增**决策 58**(`mm1_metrics` 的 4 条待拍板);「下一步」推进到第七轮;
验证计数更新为 **202 passed / 28 skipped(230 收集)**。
- `docs/dev/personal-pitfalls.md`:新增易错点 29(同一个校验里的两把尺子)。
### Notes
- **待用户拍板的口径**(Codex 只报不改):`markov_simulate` 4 条(`initial` 语义 A/B/C、`paths[:, 0]` 赋值位置、
状态越界、`std_terminal` 对分类量)、`markov_steady_state` 3+2 条(`tol` 的 NaN、`max_iter` 类型错、
非负校验的**容差**、未收敛却带 `data`)、`mm1_metrics` 4 条(`λ = 0`、numpy 标量、`data=metrics`、
失败分支丢 `rho`)。详见 `STATUS.md` 决策 56 / 57 / 58。
### Added
- `tests/test_solve.py`:本轮再 +2(`markov_simulate` 的 `terminal_distribution` 口径、
`mm1_metrics` 的 `λ = 0` 空系统),并把受口径变更影响的 5 条测试同步到新行为。
### Changed
- `markov_simulate`:非负校验带容差 `-1e-12`(浮点归一化的 `-1e-16` 残渣不再被误判);
`initial` 收 `np.integer`;`paths` 构造与首列赋值挪进 `try`(长度不匹配 / 状态越界不再裸抛);
`params` 键 `n_steps` → `steps`、`initial` 存成可 JSON 化的标量 / 列表;
`metrics` 的 `std_terminal` → `terminal_distribution`(`{状态: 频率}`,只列出现过的状态)。
- `markov_steady_state`:非负校验带容差;`max_iter` 类型错改抛 `TypeError`;`tol` 增加 `np.isnan` 拦截;
非方阵改抛 `TypeError`。
- `mm1_metrics`:`arrival_rate` 放开为**非负**(`λ = 0` = 空系统,`L = Lq = Wq = 0`、`W = 1/μ` 取极限值);
两个速率收 `np.number`;不稳定分支保留 `rho` 并写进 message;`data=metrics.copy()`(去掉同一个 dict 对象的别名)。
- docstring 同步:`markov_simulate` 的 `Returns` 与"遗留"段、`markov_steady_state` 的 `Raises`(TypeError / ValueError 分工)、
`mm1_metrics` 的 `Parameters` / `Returns` / `Raises`。
- ruff:`B905`(`zip` 缺 `strict`)+ 2 条空白 → 0;`markov_simulate` 里 `rng` 之后的双空行 → 1。
- `STATUS.md`:决策 56 / 57 / 58 更新到"只剩 5 条小口径",「下一步」推进到第八轮,
并写入**第八轮备忘:`models` 与 `decision` 各是干什么的**(含两包的 19 / 8 个函数清单、使用顺序、建议开工顺序)。
- `docs/dev/personal-pitfalls.md`:新增易错点 30(口径一改,docstring / 测试 / STATUS 三处会同时过期)。
### Notes
- **只剩 5 条口径待拍板**(Codex 只报不改):`markov_simulate` 的 `bool` 漏网与 `initial` 的 A/B/C、
`markov_steady_state` 未收敛时是否带 `data`、非方阵的 `TypeError` / `ValueError` 不一致、
`mm1_metrics` 的 `data` 该不该是 dict。
### Added
- `models/metrics.py`:6 个回归指标落地(**由用户本人实现**)—— `rmse` / `mae` / `mape` / `smape` /
`r2_score` / `adjusted_r2`,共用一个入口守卫 `verify_yt_yp`(`as_array` → 形状 / 空 / NaN 三道校验)。
`compare_models` 仍是 `TODO(L1)`。
- `tests/test_models.py`:5 条 → **23 条**。`test_metrics_known_values` / `test_r2_is_one_for_perfect_fit`
两条摘掉 `skip` 转正,另新增 18 条 —— 形状不一致 / 空 / NaN / `str` 被 `TypeError` 挡、四种入参
(`DataFrame` / `Series` / `ndarray` / `list`)结果一致 ×5、`mape` 遇 0、`smape` 全 0 且**不许漏告警**、
`adjusted_r2` 的 `bool` / `float` / `0` / 分母保护 / `np.int64` / 惩罚单调性,以及 `r2_score` 的
**非完美拟合**解析值与 `sklearn.metrics.r2_score` 对照。
### Changed
- `models/metrics.py`:`r2_score` 的 `SS_tot` 基准由 `np.mean(y_pred)` 改为 `np.mean(y_true)`(**真 bug**,
由用户亲手改定,见 STATUS 决策 59 / 个人易错点 31),`ss_tol` 随之改名 `ss_tot`;修前 0.8803827751196172、
修后 0.875(与 sklearn 一致),`adjusted_r2` 同步修正。
- `models/metrics.py`:`smape` 的零除改用 `np.divide(..., out=..., where=denom != 0.0)` ——
`np.where` 的两条分支都会被求值,原写法在 `filterwarnings = ["error::RuntimeWarning"]` 下会让
"含 0 的 smape"测试直接失败(决策 60)。数值结果与口径不变。
- `models/metrics.py`:补 7 处 docstring(`Parameters` / `Returns` / `Raises`),把"NaN 报错 / `mape` 遇 0 报错 /
`r2_score` 常量 y 报错 / `adjusted_r2` 的分母保护"写成契约;`n_features` 的口径写明**不含截距**。
- `models/metrics.py`:报错文案统一为"参数名 + 实际值"(形状不一致会带上两个 `shape`);
ruff 9 条清零(6 条 `F541` 无占位符 f-string + 3 条 `W293`)+ 补函数间空行。
- `STATUS.md`:新增**决策 59 / 60**,「下一步」推进到第九轮(含 4 条待拍板口径);
验证计数更新为 **224 passed / 26 skipped(250 收集)**。
- `docs/dev/personal-pitfalls.md`:新增易错点 31(公式的"基准量"抄错对象,且完美拟合会掩盖它)、
32(`np.where` 掩码防不住零除)。
### Notes
- **4 条口径待用户拍板**(Codex 只报不改):`mape` 遇负 `y_true` 现在返回负数、只拦 `NaN` 不拦 `inf`、
`rmse(1.0, 1.0)` 这类标量被当成 1 个样本、`compare_models` 的键名 `"r2"` 与函数名 `r2_score` 不一致。
详见 `STATUS.md`「第九轮新增:4 条口径待你拍板」。
### Added
- `models.compare_models`:落地(**Codex 按 A 档实现**,决策 62)。返回 `pd.DataFrame`(行 = 模型、列 = 指标),
按第一个指标排序(误差类升序、R² 降序)。`cv=0` 全量拟合后打分;`cv >= 2` 用**手写 K 折**
(`get_rng(random_state).permutation` + `np.array_split`,**不引入 sklearn 依赖**)报折间均值。
- `models/metrics.py` 的**指标名别名映射** `_METRIC_FUNCS`:`"r2"` 与 `"r2_score"` 指向同一个函数,
传哪个名字列名就叫哪个;两个一起传报 `ValueError`。`adjusted_r2` 不进表(需要 `n_features`)。
- `tests/test_models.py`:23 → **31 条**。首列排序(升序 / 降序)、别名映射、坏指标名 / 坏 `models` / 坏 `cv`、
每折样本数下限、`random_state` 可复现;另新增 `Inf` 与"至少 2 个样本"两条入口测试。
原 `test_compare_models_returns_table_sorted_by_first_metric` 的**用法是错的**(把拟合结果当模型传),
已改写成用测试桩 `_MeanModel` / `_LinearModel` 并摘掉 `skip`。
### Changed
- `models/metrics.py`:用户的第三轮入口改动入库(决策 61)—— `verify_yt_yp` 增加 `Inf` 拦截、
增加"至少 2 个样本"(标量不再被当成 1 个样本);`mape` 由"只拦 0"改成拦 `y_true <= 0`
(原式对负数返回**负数**,实测 `-1.0`)。
- `models/metrics.py`:三条新口径同步进 7 处 `Raises`,`mape` 的 `Returns` 与摘要改为"`y_true` 必须全为正数"
(**易错点 30 复发 1 次**:口径一改、docstring 立刻过期)。
- `models/metrics.py`:`compare_models` 的入口收口 —— `X` 必须二维(`as_array(ndim=2)` 不替你猜)、
`cv` 白名单(`bool` 拒收 / `cv=1` 无意义 / `cv <= n // 2`)、`models` 必须是"有 `fit` / `predict` 的对象"、
`metrics` 必须是 `tuple` / `list`(单个字符串给专门的报错文案)。
- `models/metrics.py`:模块 docstring 改为"指标函数只返回 `float`,唯一例外是 `compare_models`(返回 `DataFrame`)"。
- `STATUS.md`:新增**决策 61 / 62**,第九轮那 4 条口径标记办结,「下一步」推进到第十轮;
验证计数更新为 **233 passed / 25 skipped(258 收集)**。
- `docs/dev/personal-pitfalls.md`:易错点 30 记为**复发 1 次**;新增易错点 33
(多行报错文案少引号 / `raise ValueError("a", "b")` 多参数 —— ruff 一秒能抓,别等运行时)。
### Notes
- **1 条小口径待用户拍板**:`verify_yt_yp` 里 `yt.ndim == 0` 是死代码(`as_array(ndim=1)` 必然摊平),
可塌成 `if yt.shape[0] <= 1:`。详见 `STATUS.md`「第十轮新出现的 1 条小口径」。
- **等 `fit_linear` 落地时补一条集成测试**:`compare_models` + 真实模型对象。
## [0.0.1] - 2026-10-01
### Added
- 项目初始化,包结构落盘(实现为空)。
+16 -13
View File
@@ -1,18 +1,21 @@
MIT License MIT License
Copyright (c) 2026 guzhujushi Copyright (c) 2026 孤竹居士
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Permission is hereby granted, free of charge, to any person obtaining a copy
associated documentation files (the "Software"), to deal in the Software without restriction, including of this software and associated documentation files (the "Software"), to deal
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell in the Software without restriction, including without limitation the rights
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
following conditions: 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 The above copyright notice and this permission notice shall be included in all
portions of the Software. copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
USE OR OTHER DEALINGS IN THE SOFTWARE. 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.
+71 -2
View File
@@ -1,3 +1,72 @@
# MCM-ICM_Toolkit # MCM/ICM Toolkit
这是我参加美赛时用的学习和准备复用的代码,有需要可以自行克隆和修改 > 这是我参加美赛时用的学习和准备复用的代码,有需要可以自行克隆和修改。
美赛(MCM/ICM)编程手的**竞赛层工具箱**:
把比赛中反复手写的代码沉淀成"高频算法自己实现 + 成熟库薄封装 + 统一结果接口"。
> **不**替代 SciPy / scikit-learn / statsmodels / NetworkX / SALib / SymPy。
> 只做它们到"4 天赛程能用"之间的那一层。
## 快速开始
```powershell
# 1) 建环境(两条路选一条)
.\scripts\setup-venv.ps1 # venv,零下载,复用 Anaconda base 的科学栈
.\scripts\setup-conda.ps1 # conda 环境 mcm-toolkit(精准包列表,走代理下载)
# 2) 自检
.\scripts\doctor.ps1
# 3) 激活
.\.venv\Scripts\Activate.ps1
```
```python
import mcm_toolkit as mcm
mcm.set_seed(42)
r = mcm.bootstrap_ci(data, statistic=np.median, random_state=42)
print(r.params["low"], r.params["high"])
```
## 当前状态(v0.0.1)
本地 git 仓库已初始化(`main`,首次提交 `5319589`),远程 Gitea 待配置。
**接口已定,实现未填。** 所有函数体是 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记,
按 [docs/03-roadmap.md](docs/03-roadmap.md) 的 L0 → L1 顺序填。
## 目录
| 路径 | 说明 |
|---|---|
| `src/mcm_toolkit/` | 包源码,7 个子包 |
| `tests/` | 测试,与包一一对应(测已知解析解,不测实现细节) |
| `examples/` | 每个模块一个能跑的最小例子 |
| `problems/` | 历年真题(每做完一题,反向沉淀 1~3 个通用函数) |
| `notebooks/` | 探索用 |
| `docs/` | 需求、架构、路线图、环境、规范 |
| `scripts/` | 环境脚本 |
| `STATUS.md` | 当前进度 |
## 子包
| 包 | 职责 | 关键函数 |
|---|---|---|
| `core` | 基础设施 | `Result` `get_rng` `check_*` |
| `data` | 读 / 洗 / 变换 / 采样 | `load_table` `detect_outliers` `normalize` |
| `stats` | 描述 / 推断 / bootstrap | `describe` `bootstrap_ci` `fit_distribution` |
| `models` | 回归 / 时序 / ML | `fit_linear` `fit_arima` `compare_models` |
| `solve` | 优化 / 模拟 / 敏感性 | `monte_carlo` `simulated_annealing` `sobol_analyze` |
| `decision` | 评价决策 | `entropy_weights` `ahp_weights` `topsis` `gm11` |
| `viz` | 出图出表 | `plot` `save_figure` `save_table` |
## 文档
开始动手前请先读 [docs/01-suggestion-review.md](docs/01-suggestion-review.md)——
它记录了"为什么最终结构是这样",以及**明确不做什么**。
## 许可
MIT,见 [LICENSE](LICENSE)。
+914
View File
@@ -0,0 +1,914 @@
# STATUS — 当前进度
> 更新日期:2026-10-18
## 本次任务
**第一阶段(已完成)**:审查并精简 ChatGPT 的建库建议 → 生成骨架文件 →
配置 conda 与 venv 环境 → 初始化 git 仓库 → 接入 Gitea 远端。
两套环境均已建成并通过验证,仓库已推送到 `code.guzhujushi.cn`。
**第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**。
**分工硬规矩(决策 47,用户 2026-10-03 明确)**:
- **算法一律由用户本人写**(S 档尤其)—— 即便用户说"你写吧"也不代写,
要保住"一边学一边自己写"的路径。
- **工程事务由 Codex 写、用户读**:入口/校验重构、`tests/`、docstring、ruff、
`STATUS.md` / `CHANGELOG.md`、函数卡片、`.gitignore` 等。
- A 档**薄封装**(`rmse`/`mae`/`fit_linear`/`train_test_split`…)算工程,Codex 可直接实现。
- 分地盘按**文件**划,不按函数划(同一文件两人各写一半曾导致用户编辑器保存失败)。
当前 **L0 12/12(已全部完成)**:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
`detect_outliers`、`normalize`/`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`、
`monte_carlo`(**用户写**,2026-10-04);另**提前落地**了 L1 的 `confidence_interval` / `t_test` /
`monte_carlo_probability`、`monte_carlo_integral`、`random_walk`、`markov_simulate`、
`markov_steady_state`、`mm1_metrics`(**均为用户写**)—— `solve` 层的 L1 清单**已全部落地**。
`save_figure` 已于 2026-10-18 由 Codex 落地,L0 收官。
**`models/metrics.py` 已完成(第九~十轮)**:6 个指标(`rmse` / `mae` / `mape` / `smape` / `r2_score` /
`adjusted_r2`)+ 共享入口 `verify_yt_yp` **由用户写**、Codex 审;`compare_models` 由 Codex 按 A 档落地
(含指标名**别名映射**)。`models` 下一步是 `regression.py`。
`monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**;
`markov_simulate`(决策 56)/ `markov_steady_state`(决策 57)/ `mm1_metrics`(决策 58)
的入口口径**已办掉大半**,各剩 1~2 条细枝末节(详见"下一步"第八轮)。
另 3 个小口径仍悬着:`ci_low`/`ci_high` 的语义、`monte_carlo_integral` 的第一道 `isinstance` 分支可塌成两行、
`random_state` 注解与 `std` 的 `ddof` 是否全项目对齐。
## 任务清单
### ✅ 已完成
**A. 建议审查与精简**
- [x] 通读 `chatGPT_Suggestion.md`(33 节,约 2.4 万字),输出 `docs/01-suggestion-review.md`
- 总裁决:**方向对,量级错**
- 14 个包 → **7 个**;约 250 个函数 → **约 40 个**;5 个 Result 类 → **1 个**
- 单函数文档要求 10 项 → **2 档**(只有约 15 个招牌算法写完整卡片)
- 逐项给出"砍什么 / 为什么 / 替代什么",并补上原文缺失的六件事
(论文产出链路、指标方向与量纲、时间预算、可复现性、测试测什么、离线约束)
**B. 骨架文件生成**(76 个文件,约 6800 行)
- [x] `docs/02-architecture.md` 架构、`docs/03-roadmap.md` 路线图与**永久不做清单**
- [x] `docs/04-environment.md` 环境文档、`docs/dev/function-card-template.md` 卡片模板
- [x] `src/mcm_toolkit/` 7 个包 23 个模块(**只有签名 + docstring + `NotImplementedError`**)
- [x] `tests/` 9 个文件、`examples/` 4 个、`problems/` `notebooks/` `figures/` `tables/`
- [x] `pyproject.toml`(核心依赖 4 个 + 6 组 extras)、`environment.yml`
- [x] `.gitignore` `.gitattributes` `.editorconfig` `LICENSE` `CHANGELOG.md` `README.md`
- [x] 脚本 `scripts/setup-venv.ps1` `setup-conda.ps1` `doctor.ps1`
**C. 环境配置**
- [x] **venv 已建好**:`.venv`(10.5 MB,Anaconda base + `--system-site-packages`)
+ `pip install -e . --no-deps --no-build-isolation`;
可直接导入 numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6 / matplotlib 3.11.2 /
sklearn 1.9.1 / statsmodels 0.15.0 / networkx 3.6.1 / sympy 1.14.0
(版本随 base 走,base 升级后自动同步)
- [x] **conda 环境已建好**:`D:\anaconda\envs\mcm-toolkit`(1.55 GB,Python 3.13.15)
- 科学栈:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6 / matplotlib 3.11.2 /
sklearn 1.9.1 / statsmodels 0.15.0 / networkx 3.6.1 / sympy 1.14.0(MKL 后端)
- 已补装 pip-only 依赖:SALib、pymoo 0.6.2、pyright 1.1.414
- 已在本项目中可编辑安装
- 实测耗时 868 秒,下载约 3 GB
- [x] **Anaconda base 已升级** 2025.12 → **2026.07**(365 个包,下载 638 MB,耗时 1033 秒)
- conda 自身 25.11.1 → **26.7.3**(实测复核)
- 并把项目环境 numpy 对齐到 base 的 2.4.6,**两套环境数值栈现已完全一致**(MKL 后端)
- 升级前已用 `conda list -n base --revisions` 留了回滚点
- [x] 环境脚本按实测结果重写:`setup-conda.ps1` 默认改为**精准包列表**
(`--clone base` 实测会走完整下载安装,不再是"离线快捷方式")
**D. Git 仓库**
- [x] `git init -b main`,`core.autocrlf=false`,首次提交
`5319589`(76 文件 / 6812 行),工作区干净
- [x] `git ls-files --eol` 复核:**全部 LF**,`.gitattributes` 生效
- [x] 仓库由**本人账户 guzhu** 创建(见决策 17)
- [x] **远端 Gitea 已接通并推送**:`origin` = `git@code.guzhujushi.cn:guzhujushi/MCM-ICM_Toolkit.git`,
`main` 跟踪 `origin/main`,远端 HEAD = `96080b9`,76 个文件已在服务器上(见决策 19/20)
- [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`),
`~/.ssh/config` 加了一条域名别名,remote 里仍写域名
**E. L0 实现进度(10/12)**
- [x] `core/result.py` 的 `Result`
- `__repr__`:单行紧凑;`data` 只给类型与规模,**绝不**整数组打印
(十万级数组的 repr 实测仍 < 200 字符);`success` 恒在
- `summary()`:Markdown 表格,可直接贴进论文;
行序 `success` → `params.*` → `metrics.*` → `extras.*`;
float 走 `.6g`,`|` 转义,`message` 以引用行附在表后;幂等无副作用
- 测试:`tests/test_core.py` 去掉 skip + 新增 3 条
(默认值/失败路径/大数组、Markdown 结构、可重复调用)
- [x] `core/random.py` 的 `get_rng` / `set_seed`(**设计由用户本人写**,Codex 补齐类型校验)
- 无参 `get_rng()` 复用**同一个模块级生成器**且会推进:既保证重跑一致,
又避免两个都省略 `random_state` 的函数抽到同一串数(原决策 24 的取舍已消掉)
- `get_rng` 收 `int` / `np.integer` / `Generator` / `None`,其余类型**抛 `TypeError`**:
`np.int64` 不是 `int` 的子类,曾因此静默退回全局流
- `set_seed` 同时调 `np.random.seed`,覆盖 sklearn / statsmodels 走的 numpy 旧全局
(全项目**唯一**碰旧接口的地方,带 `# noqa: NPY002` 与理由)
- 测试:`TestRandom` 共 7 条(可复现、透传、无参跟随、旧全局、np.integer、非法类型、共享推进)
- [x] `data/io.py` 的 `load_table`(+ `save_table`,原属 L1,顺手落地)
- 实现由**用户本人**写(决策 25),Codex 只做审查 + 补测试
- `.csv` / `.xlsx` / `.xls` 按扩展名分派;`.csv` 默认编码探测
utf-8 → utf-8-sig → gbk → gb18030,显式传 `encoding=` 时不再探测
- `na_values` 默认吃下空串 / `NA` / `N/A` / `#N/A` / `null` / `-`
- 不支持的扩展名抛 `ValueError`;文件不存在抛带路径的 `FileNotFoundError`
- `save_table` 的校验顺序已修正:后缀判断提到 `mkdir` 之前
(此前传 `.txt` 会先留下一个空目录再抛错)
- 测试:`tests/test_data.py` 由 1 条(且被模块级 skip 掉)扩到 **9 条**
- docstring 的 `Parameters` / `Raises` 已按 §4.7 补齐(Codex 代写,用户授权)——
numpydoc 格式,对齐参考实现 `core/random.py` 的 `get_rng`
- [x] `data/clean.py` 的 `clean_missing`(L0 第 4 项)
- 实现由**用户本人**写,Codex 只做审查、补测试与文档
- `threshold` 用 `math.ceil` 算 `min_non_na`:3 列、阈值 0.5 时必须是 2,
写 `int()` 截断会漏删
- 顺手落地兄弟函数 `impute_missing` / `winsorize`(原属 L1)
- [x] `data/clean.py` 的 `detect_outliers`(L0 第 5 项,S 档,**实现由用户本人写**)
- 语义由用户本人定:**非数值列不参与统计,掩码里恒 `False`**(见决策 32)
- 三轮修掉的 4 个 bug:`threshold` 范围校验放错函数、IQR 上界符号、
`is_series` / `is_ld_array` 判在类型转换之后(永远为 False)、文本列上算分位数直接崩
- 关键教训:**限制掩码 ≠ 限制统计量** —— 只把 `mask[numeric_cols] = ...`
收窄是不够的,`quantile()` / `mean()` / `std()` 也要一起收窄(见坑 8 / 个人易错点 8)
- 返回形状与输入同形;Series 进 Series 出(列名保留)、一维数组进一维布尔数组出
- **S 档函数卡片已写**:`docs/algorithms/detect_outliers.md`
(含"为什么不返回 `Result`"、非数值列恒 `False` 的两条备选路、`ddof` 口径不一致等)
- 测试:`tests/test_data.py` 该函数共 **9 条**
- [x] `data/clean.py` 的两处收尾(**Codex 代写,用户授权**)
- `impute_missing` 的 mode 分支改为**返回新表**(原先就地改调用方的表,与另外四支不一致)
—— 最后一条 `xfail` 随之转正,**`xfail` 清零**
- `detect_outliers` 补**入参类型校验**(§4.7 要求非法类型抛 `TypeError`):
接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`,`dict` 不再接受(决策 33)
- [x] `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` /
`impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测可直接跑通
- [x] `data/transform.py` 的四个函数(**由用户本人实现**):`normalize` / `standardize`(L0 第 6、7 项)、
`log_transform` / `one_hot_encode`(原属 L1,顺手写了)
- `normalize`:三种 method(`minmax` / `vector` / `max`)都**按列**;`feature_range` 校验下界 < 上界;
常量列压到下界而不出 nan(决策 34)
- `standardize`:按列,`ddof` 真的传下去 —— 测试用解析关系钉住
(`ddof=0` → std 恰为 1;`ddof=1` → `sqrt((n-1)/n)` = 0.866)
- `log_transform`:`offset=None` 时自动取 `1 - min(x)`,保证 `x + offset >= 1`
- `one_hot_encode`:自动识别的 dtype 集合已显式补上 `'str'` / `'string'`(见坑 9)
- Codex 清掉 5 条 ruff(RET503 / F541 / RET504 / RET505 / SIM108),并把 `feature_range`
的两条校验提到 `np.asarray` **之前**、修掉说反的报错文案(条件是"下界必须小于上界")
- 测试:**新写 18 条**,其中两条是把原先挂着的 `skip` 转为实跑 —— 见 `tests/test_data.py` 的 transform 段
- [x] **`transform.py` 的 4 个口径已收口**(2026-10-02,Codex 代修,用户授权"报错顺手修掉"):
- 输入白名单统一成模块级 `_NUMERIC_TYPES`,**`DataFrame` 补进去**并移到 `np.asarray` 之前
(此前 `DataFrame` 不在白名单 → `standardize(df)` / `log_transform(df)` 直接 `TypeError`,
函数体里所有 `is_df` 分支都是**死代码**)
- 三个函数都保持"DataFrame 进 DataFrame 出"(列名 + 索引原样带回),Series 进 Series 出
- `normalize` 的三个 method 分支原来**互相覆盖**(`minmax` / `vector` 算完的结果被末尾
无条件的 `x / max` 冲掉,三种 method 实际都只走 max),改成 `if / elif / else`
- `one_hot_encode` 不再拿 `data.columns` 重包一层(哑变量列名对不上 → 编码列整列丢失)
- `log_transform` 的 `offset` 守卫**写反了**(`offset > 0` 就抛错,恰好拒掉唯一合法用例),
改成校验真正的定义域前提 `min(x) + offset > 0`(见决策 35)
- docstring 按 §4.7 补齐 `Parameters` / `Returns` / `Raises`(Codex 代写,与 `io.py` 同一套 numpydoc 格式)
- 口径定稿后 ruff 4 条 RET505 一并清掉 → `All checks passed`;pyright **2 errors → 0**
- [x] `stats/descriptive.py` 的 `describe`(L0 第 8 项,**由用户本人实现**,Codex 审查 + 代修)
- **一个静默 bug(最危险的那种)**:`df_num.describe().T` 把表转反了(行 = 变量、列 = 指标),
而 docstring 与 `test_describe_reports_expected_keys` 都要求"行 = 指标、列 = 变量"。
转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名**零交集**,pandas 不报错,
直接把这四行(`missing` / `missing_ratio` / `skewness` / `kurtosis`)**整行填成 NaN**。
去掉 `.T` 一处即修好三件事:方向对了、NaN 没了、`weights` 分支不再报
`cannot set a row with mismatched columns`(见易错点 11、决策 36)
- `median` 行补上(pandas 的 `describe()` 只有 `50%`,而测试契约定的是 `median`)
- 入参放宽到 `list` / `tuple` / `ndarray`(兄弟函数都收,`00_quickstart.py` 传的就是 ndarray)
- `weights` 校验补齐:长度、非一维、NaN/inf、负值、和为 0 都抛带原因的错误
(原先和为 0 时裸抛 `ZeroDivisionError`)
- `zscore`:修 RET505,docstring 补 `Parameters` / `Returns` / `Raises`;
`coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError`(那是取值问题不是类型问题)、
空数组提前报错(原先静默返回 nan + RuntimeWarning)、改掉"异变系数"错别字;分母改用 `|mean|`(**用户本人定**,见决策 38)
- pyright 的 `DataFrame | Series` 重载问题按坑 8 用 `cast` 收窄;ruff 7 条清到 0
- 测试:`test_stats.py` 去掉**模块级** `pytestmark`(它把 6 条测试**全部**跳过了 ——
决策 26 说的就是这个,实现写完了却零覆盖),改成逐条 skip 未实现的 5 条,
并新写 20 条 describe / zscore / 变异系数用例
- [x] `examples/01_clean_and_describe.py`:填掉末尾的 `TODO(L0)`,加一段 `describe` 输出
- [x] `stats/inference.py` 的 `correlation_matrix`(L0 第 9 项,**由用户本人实现**,Codex 审查 + 代修)
- **硬 bug**:`if numeric_cols == 0:` 拿 `Index` 和标量比,得到的是布尔**数组** ——
0 列时静默不报错、1 列时碰巧能跑、**≥2 列直接 `ValueError` 崩**;
而"≥2 个数值列"正是最常见的情况,等于整个函数不可用。改成 `len(numeric_cols) == 0`(易错点 13)
- pyright 的报错也都不是运行时问题:`data[numeric_cols]` 是坑 8 的 `DataFrame | Series` 重载
(`cast` 收窄);`_, p = stats.pearsonr(...)` 解包会退化成 `object` —— scipy 的返回值是
`_make_tuple_bunch` 运行时拼的类、存根里看不到 `pvalue`(见 `docs/04-environment.md` 坑 10)。
加了 `_pvalue(result: Any)` 小助手统一取 p 值,**一处都没挂 `# type: ignore`**
- `method` 校验提到最前、清掉 3 条 F541 / 1 条 RET505 / I001,ruff 归零
- **现在返回**:`correlation_matrix` → 数值系数矩阵(对角线 1.0);
`correlation_pvalues` → 数值 p 值矩阵(**对角线 NaN**);
`correlation_table` → 星标字符串表(**对角线 `1.000` 不带星**)。
- **返回契约冲突(已解决)**:上一版返回的是**带星标的字符串表**(`1.000***`),
而 docstring 契约与 L0 用例都要求**数值矩阵**(对角 = 1.0);按方案 A 拆成三个函数(见下)
- [x] `correlation_matrix` 的**返回契约**按**方案 A** 落地(2026-10-03,用户拍板):
`correlation_matrix` 只返回**数值**矩阵;星标表拆成 `correlation_table`、p 值拆成
`correlation_pvalues`(三者共用 `_check_corr_args` 守卫与**成对删缺失**口径,见决策 41)。
顺带修掉两处:**对角线不再带 `***`**(p 值矩阵的对角线是 NaN、"自己和自己"没有显著性);
**p 值与 r 用同一批观测**(都成对删缺失、都尊重 `min_periods`),不再各算各的样本量。
p 值只算上三角再镜像,省一半重复计算。测试 **+8 条**(含用解析式 `t = r·sqrt((n-2)/(1-r²))` 对照)
- [x] `stats/inference.py` 的 `confidence_interval` / `t_test`(**L1 提前落地,实现由用户本人写**,Codex 审查 + 代修)
- **用户问的「307/308 两行报错」不是真错**:那两行是 `float(res.statistic)` / `float(res.pvalue)`,
报错来自 scipy 的 `TtestResult` —— 它和 `PearsonRResult` 一样是 `_make_tuple_bunch` **运行时**拼的类,
存根里没有这两个字段。探针实测 `TtestResult(statistic=3.873, pvalue=0.0305)`,运行时取值完全正常。
按决策 40 的路子补了一个 `_statistic(result: Any) -> float` 助手(与 `_pvalue` 成对),
**一行 `# type: ignore` 都没挂**。详见 `docs/04-environment.md` 坑 10
- **`confidence_interval` 有真 bug(已修)**:原来返回 `(float(min(0.0, lower)), float(max(upper, 1.0)))` ——
`min` / `max` 的**参数写反**(下界被钉死成 `0.0`),而且**无条件**把所有 method 的区间夹到 `[0, 1]`。
实测 `confidence_interval(rng.normal(170, 8, 50), method="t")` 的下界是 `0.0`(正确是 `171.0 ± 2.1`);
`confidence=1.5` 更是被夹成 `(0.0, nan)` 而不报错。现在**只有 `proportion` 夹**(决策 42),
`normal` / `t` 直接返回 `mean ± z|t·se`;`method` 非法改抛 `ValueError`(§4.7)、
`confidence` 按 `(0, 1)` **开区间**校验、样本量 < 2 抛 `ValueError`
- `confidence_interval` 的入参放宽到 `list` / `tuple` / `ndarray` / `Series` / `DataFrame`:
docstring 的 Examples 传 list、L0 用例传 ndarray,原来**两条都直接 `TypeError`**
- `t_test`:`_clean_data` 改用 `isinstance(data, pd.DataFrame)` 收窄(坑 8 的 `cast`)、`alpha` 开区间校验、
`df` 按 Welch–Satterthwaite / pooled / paired 分别给,docstring 补齐 `Parameters` / `Returns` / `Raises` / `Notes`(§4.7)
- 测试 `tests/test_stats.py`:**+10 条(36 → 46)**,含把原先挂着 skip 的 `test_confidence_interval_matches_scipy` 转正;
对照一律用解析式(z 公式、Welch `df` 公式、`t = (mean − value)/(s/√n)`),不拿实现对自己
- ⚠️ **`t_test` 还有 3 个口径待用户拍板**(Codex 未改,见「下一步」第 6 条)
- [x] **`core/validate.py` 的 `as_array`**(统一入口,2026-10-03 落地)
- 用户提出"每个函数都抄一遍入口/校验/交换太麻烦"。结论:**入口能统一,出口不能一刀切** ——
还原要求"输出与输入同形",而 `describe` / `correlation_*` / `confidence_interval` / `t_test`
的输出形状天生和输入不同,硬套只会重演易错点 14。
- 契约:`as_array(x, *, numeric_only=True, dtype=float, ndim=None) -> (arr, kind)`,
`kind` 是 `"frame"` / `"series"` / `"array"`(**只报告原类型,还原由调用方手写**,用户拍的板)。
- 覆盖掉重复:原先 `select_dtypes` / `isinstance(..., pd.DataFrame|Series)` 散在 **5 个文件 31 处**;
现在 `normalize` / `standardize` / `log_transform` / `zscore` / `confidence_interval` / `t_test`
各只剩 1 行入口。`data/transform.py` 的 `_NUMERIC_TYPES` 白名单连同 3 份副本一起删掉。
- 两条硬规矩:**默认绝不摊平**(要摊平显式 `ndim=1`);**不删缺失值**(删缺失是各函数的语义,
见决策 45 与易错点 15)。`numeric_only` 按"输出是否与输入同形"选,不按喜好选(决策 44)。
- `check_array`(L1,未实现)实现时直接复用 `as_array`,一份实现两张脸
- [x] `stats/bootstrap.py` 的 `bootstrap_ci`(L0 第 10 项,**S 档**,实现由用户本人写,Codex 审查 + 代修)
- 移植正确性已用**解析解**钉住:大样本正态下百分位区间半宽 ≈ `2 × 1.96 × se`(容差 10%)
- **Codex 代修的三处**:
① `random_state=None` 时原来另开了 `np.random.default_rng()`,**绕过了 `get_rng`** ——
与决策 24 冲突(`set_seed` 后仍然不可复现)。改成无条件的 `get_rng(random_state)`;
② `statistic_name` 写成 `hasattr(statistic, "__name__")`,而 lambda 的 `__name__` 恒为 `"<lambda>"`,
导致 `else` 分支是**死代码**、自定义统计量拿不到 `"custom"`(见易错点 17);
③ ruff 两条(I001 导入块、F541 空 f-string),并把 docstring 的 `Parameters` / `Returns` / `Raises` 补齐(§4.7)
- **测试是重点**:用户实现写完后,`tests/test_stats.py` 里等它的 3 条**仍然挂着 `skip`**,
全量照样显示"全绿" —— 正是易错点 9 的复发(已把该条 `复发` 记到 1)。
已摘掉 3 条 skip,并新增 6 条:`estimate` 等于原样本统计量、`confidence` 单调性、
五种容器一致、自定义 lambda 走回退分支、**不给种子时跟随 `set_seed`**、非法 `method` 抛错。
- **S 档函数卡片已写**:`docs/algorithms/bootstrap_ci.md`(第 2 张),
记了"极差统计量的区间上界必然塌到 `estimate`"这个非平凡性质,以及 5 个待拍板口径
- 测试:`tests/test_stats.py` 的 bootstrap 段共 **9 条**
- [x] **工程收尾(本轮)**:`tests/test_solve.py` 的模块级 `pytestmark = pytest.mark.skip(...)`
拆成 **9 条逐条** `@pytest.mark.skip(reason=_NOT_IMPLEMENTED)` —— 模块级 skip 会把整个文件吞掉,
正是易错点 9 的根源(决策 26)。收集数不变(177)、跳过数不变(34),只是粒度变细。
- [x] `solve/simulate.py` 的 `monte_carlo`(**L0 第 11 项,S 档,算法由用户本人写**,2026-10-04)
- 核心逻辑:向量化采样 → 一次仿真 → 8 个指标;`get_rng` 无条件调用(决策 46 已内化)
- 用户第二轮改了三处(见决策 48):批量大小改由实现传 `sampler(rng, size=n)`;
结果含 NaN 判失败;`np.std(data, ddof=0)` 写明
- **Codex 只碰工程侧,算法一行未动**:
① ruff 三条(I001 合并同源 import、F541 空 f-string ×2)
② docstring:补 `Parameters`(原文漏 `random_state` / `**kwargs`)与 `Raises`;按新契约改写
`sampler` 说明(必须能接住 `size`,且 `**kwargs` 里别再传 `size`);`Notes` 记 NaN 口径;
**修掉跑不通的 Examples**(原版 `lambda rng, **k: rng.normal(0, 1)` 返回标量,实测 `success=False`)
③ 按 §4.7 收严 `n` 校验:`np.int64` 不再被拒、`n=True` 改抛 `TypeError`、浮点 `n` 归 `TypeError`、
`n<=0` 归 `ValueError`
④ 格式与文案:`success= True` → `success=True`;失败文案"重采样" → "模拟"(从 `bootstrap_ci` 抄来没改)
⑤ **入口统一 `dtype=float`** —— 修掉一个真 bug:布尔结果("事件是否发生",MC 最常见的用法)
会让 `np.std` 在 numpy 2 里抛 `boolean subtract ... is not supported`,整个函数判失败;
字符串结果也从"ufunc isnan 不支持"变成可读的 `could not convert string to float`
- **测试:10 条**(`tests/test_solve.py`)—— 返回长度与 8 个 key、`simulator` 只被调用一次(向量化的可执行版本)、
同种子可复现、省略种子时跟随 `set_seed`、E[X²]=1(n=2e5)、docstring Example 真能跑、`np.int64` 的 n、
布尔结果按 0/1 统计、NaN 结果判失败、5 个非法入参
- 行为探针实测:`simulator` 调用次数 = **1**(真向量化);20/20 次区间覆盖 0
- [x] `solve/simulate.py` 的 `monte_carlo_probability`(**L1,S 档,算法由用户本人写**,2026-10-05)
- 核心:向量化 `event(batch) -> bool 数组` → `p = mean(data)` → **Wilson score 区间**并夹到 `[0, 1]`
(比例量纲,沿用决策 42 的口径——同类问题没重犯)
- 用户第二轮(2026-10-05)按 6 条待办改掉 5 条:Wilson 替 Wald、`callable` 入口校验、
`confidence` 记进 `params`、`float()` 统一 ci 类型、小概率不再判失败(改成 message 里的警告)——见决策 50
- **Codex 第三轮只碰工程侧**:① `z_score^2` → `z_score**2` —— **真 bug**:`^` 是异或,
numpy 浮点直接抛 `TypeError`,被 `except` 吞成 `success=False`,6 条测试全红才暴露(易错点 21);
② message 无警告时的尾逗号;③ `{"confidence":confidence}` 的空格;④ docstring 的 "Wald" → "Wilson";
⑤ 新增 1 条对照测试:区间必须等于 `statsmodels.proportion_confint(method="wilson")`
- 行为探针实测:p≈0.5 正确(0.49991 @ n=2e5)、`confidence` 变大区间变宽、
同种子可复现、省略种子跟随 `set_seed`、`np.int64` 的 n 可收、`confidence=1.5` 抛 `ValueError`
- **Codex 只碰工程侧,算法一行未动**:
① ruff 六条(SIM300 Yoda、W291 ×2、W293 ×2、F541)
② docstring:补 `Parameters`(原文只有 Returns/Notes/Examples)与 `Raises`,
**修掉跑不通的 Examples**(原 `lambda rng, **k: rng.normal(0, 1)` 返回标量,实测 `success=False`),
并把"Wald 区间 + 夹到 [0,1]"与"`p <= 1e-3` 判失败"两条**现状**如实写进 Returns / Notes
③ 格式:`metrics` 字典缩进、`{"n":n, ...}` 空格、行尾空白
④ 文案:NaN 提示里的 "simulator" 改成 "event"(从 `monte_carlo` 抄来没改)
- **测试:摘掉 2 条 skip**(`test_monte_carlo_probability_of_standard_normal`、`test_monte_carlo_is_reproducible`
—— 实现写完还挂着 skip,正是易错点 9)并新增 5 条,`solve` 段共 24 条
- [x] `solve/simulate.py` 的 `monte_carlo_integral`(**L1,S 档,算法由用户本人写**,2026-10-05)
- 核心:`bounds` → 自己造 `(n, d)` 均匀样本 → 逐点求值 → `mean(func(X) × 体积)`;
自造 `sampler` / `simulator` 再复用 `monte_carlo` —— "造批 → 一次算 → 汇总"这套骨架的第二次复用
- **Codex 只改了一处真 bug(决策 31,纯 bug)**:体积写成 `wights = lows - highs`(名字打错 + 减反)。
奇数维体积为负:1D `x²` 于 `[0,1]` 实测 **-0.3324**(应 +1/3)、3D `x` 得 **-0.5005**(应 +1/2);
而 **2D `x·y` 得 +0.2499 "看着对"** —— 两个负号相乘抵消,把 bug 藏住了。
改成 `widths = highs - lows` 后复测 **0.3324 / 0.5005 / 1.9971(`[0,2]`)** 全部正确
- **Codex 只碰工程侧**:① docstring 补 `func` / `n` / `random_state` 三节 `Parameters` 与 `Raises`;
② `Returns` 写清"**`metrics["mean"]` 才是积分估计值**,`data` 是 `n` 个样本各自的贡献";
③ **修掉跑不通的 Examples**(原版断言 `abs(r.data - 1 / 3) < 0.01` —— `data` 是数组,
`bool(数组)` 会抛 `ValueError: truth value ... is ambiguous`),改成断言 `metrics["mean"]`;
④ 格式:`bounds_arr[:,0]` → `[:, 0]`、函数间空行(`def random_walk` 前只留了 1 个空行)
- **测试:新增 4 条 + 摘掉 1 条 skip**(`solve` 段共 30 条)—— 1D 收敛到 1/3(n=2e5)、
**3D 抓体积符号**(奇数维)、**`[0,2]` 抓宽度**(偶数维藏不住)、2D + `name`/`dimension`/`bounds` 三个 params、
`(1, 0)` 抛 `ValueError`。三条"抓符号"用例就是照着上面那个"2D 掩盖效应"设计的
- ✅ **3 条待办已由用户第二轮办结**(同日):`message` 改成 `if result.success:` / `else:` 两支(失败语义不再被覆盖);
入口补 `isinstance(bounds, (list, tuple))` + `ndim == 2 and shape[1] == 2` 两道(`[0, 1]` 从裸 `IndexError` 变成可读 `ValueError`);
`n` 注解放宽为 `int | np.integer`。⏳ 尚有 2 条新口径(`bounds` 收不收 ndarray、`if`/`else` 三行重复)见"下一步"
- [x] `solve/simulate.py` 的 `random_walk`(**L1,S 档,算法由用户本人写**,2026-10-05)
- 核心:`step_sampler(rng, size=(n_paths, n_steps))` 一次抽完整批增量 → `np.cumsum(..., axis=1)` →
首列填 `start`、其余列 `start + cumsum`。`data` 形状 `(n_paths, n_steps + 1)`
- **Codex 改了一处真 bug(决策 31,纯 bug)**:默认采样器写 `rng.integers(0, 2, size=size, dtype=float)`,
而 numpy 的 `integers` 不收 `dtype=float`(`TypeError: Unsupported dtype dtype('float64') for integers`)
—— **默认路径 100% 失败**(`success=False`、`data=None`),连 docstring 的 Examples 都跑不通。
改成 `rng.integers(0, 2, size=size) * 2 - 1`(后面的 `np.asarray(..., dtype=float)` 负责转 float)。
实测:`(5, 101)` 形状、`diff` 恒为 ±1、同种子可复现
- **Codex 只碰工程侧**:① ruff 两处 W293(只有空格的行);② docstring 补全(原只有 `step_sampler` 一节,
现补齐 `n_steps` / `n_paths` / `start` / `random_state` + `Returns` / `Raises` / `Notes`,
并写清 `step_sampler` 收的是**二维** `size=(n_paths, n_steps)`);③ Examples 增加一条"步长恒为 ±1"的断言
- **测试:新增 6 条**(此前 `random_walk` **一条测试都没有**)—— 形状与 `start` 列、默认步长恒 ±1、
同种子可复现、自定义高斯采样器(带漂移)、3 类非法入参、docstring Example 真能跑
- **用户第二轮(2026-10-05)按 4 条待办改定**:`n_steps` / `n_paths` 拆成 `TypeError`(类型)/ `ValueError`(取值);
加 `steps.shape != (n_paths, n_steps)` 与 NaN 两条校验;默认采样器换成 `rng.choice([-1.0, 1.0], size=size)`;
`params` 补 `random_state`;`metrics` 由"抄 params"改成 `mean_terminal` / `std_terminal`(终点位置,`ddof=0`)
- **Codex 第二轮又抓到一处真 bug(决策 31)**:用户把 `call_params = {...}` 写进了 `try` 里,而 `except` 分支要引用它 ——
**5 种失败输入 5 个全炸** `UnboundLocalError`(本该返回 `success=False` 的 `Result`,变成把异常抛给调用方)。
按原意把 `call_params` 移到 `try` 之前(不动任何口径),实测失败路径全部恢复正常;新增一条"必然失败"的回归测试(易错点 26)
- **测试:本轮再 +3 → 共 9 条**(新增"失败也必须返回 Result 且带 params"、NaN 判失败、`√t` 扩散);
`start="a"` 那条恢复成 `pytest.raises(TypeError)`(用户恢复了入口校验,见下)
- **第三轮(2026-10-05)**:`start` 校验恢复、注解放宽为 `int | np.integer`;最后一条"方阵歧义"用户选**方案 B**,
`step_sampler` 契约改为"整数 `size=n_paths * n_steps` + 必须返回一维 + 内部行主序 `reshape`"(决策 55,Codex 代改)
- [x] `solve/simulate.py` 的 `markov_simulate`(**L1,S 档,算法由用户本人写**,2026-10-05)
- 核心:`arr[current_states]` 取当前状态那一行 → `cumsum` 得累积分布 → 用 `[0, 1)` 均匀数"落格"
(`(cum_probs < r[:, None]).sum(axis=1)`)→ 写进下一列。`data` 形状 `(n_paths, steps + 1)`,值是整数状态编号
- **Codex 审查结论:算法本体正确**(探针实测 `(2000, 201)`、状态 ∈ {{0,1}}、200 步后终点频率
`[0.657, 0.343]` 对照平稳分布 `[2/3, 1/3]`、同种子可复现)
- **Codex 只碰工程侧**:① ruff 8 条全清(`I001` 导入排序 + 5 处 `W293` / 2 处 `W291` 空白);
② `arr, kind = as_array(...)` 的 `kind` 全程未用 → 改 `arr, _`;
③ docstring 从一行补到完整的 `Parameters` / `Returns` / `Raises` / `Notes` / `Examples`;
④ 文案:`马尔科夫` → `马尔可夫`(3 处)、`initail` → `initial`、半角逗号 → 全角;`paths[:,0]` → `paths[:, 0]`
- **测试:新增 9 条**(此前 `markov_simulate` 一条都没有)—— 形状 / 首列 / 状态取值 / 整数 dtype、
单位矩阵吸收态自锁、可复现(同种子相同、异种子不同)、终点频率收敛到 `[2/3, 1/3]`、
metrics = 终点状态、非法转移矩阵(非方阵 / 行和不为 1)、非法 `steps` / `n_paths`(0 / float / bool)、
`np.int64` 可收、docstring Example 真能跑
- **第二轮(2026-10-09)**:用户补了 `np.any(arr < 0)` 非负校验 → 待办 ② 办结;
Codex 补一条"行和是 1 但含负元素"的回归测试
- **第三轮(2026-10-10,用户改定)**:非负校验改带容差 `-1e-12`(⑦ 办结);
`initial` 注解放宽到 `(int, np.integer)`(`np.int64` 已收);`paths` 的构造与首列赋值**挪进 `try`**(③ 办结,
长度不匹配/越界不再裸抛);`params` 的键 `n_steps` → `steps`(与参数名对齐)、`initial` 存成可 JSON 化的标量/列表;
`metrics` 去掉 `std_terminal`、换成 `terminal_distribution`(`{状态: 频率}` dict,⑤ 办结)
—— Codex 同步 docstring 与 3 条测试(含"只列出现过的状态"这条口径)
- ⏳ **余下 4 条**见"下一步 → markov_simulate 待你拍板"(全是入口口径与少考虑情况,决策 49)
- [x] `solve/simulate.py` 的 `markov_steady_state`(**L1,S 档,算法由用户本人写**,2026-10-09)
- 核心:`v` 从均匀分布出发,`v_next = v @ arr` 反复推进(行随机矩阵按**行向量右乘**),
直到 `max|v_next - v| < tol` 或耗尽 `max_iter`;`data` 是平稳分布向量,`metrics={"converged", "iterations"}`
- **Codex 审查结论:算法本体正确**(探针实测)—— 2 状态 `[2/3, 1/3]`(58 次迭代)、3 状态与 `np.linalg.eig(P.T)`
特征值 1 对应的特征向量逐位一致(39 次)、随机 6 状态一致(13 次)、200×200 一致到 `1.9e-12`(6 次、0.10s)、
与 `markov_simulate` 的终点频率互验 `[0.6702, 0.3298]` vs `[0.6667, 0.3333]`、`sum(data) = 1.0`
- **Codex 只碰工程侧**:① ruff 5 条 `W293` 全清,`mm1_metrics` 前 4 个空行 → 2;
② docstring 补 `max_iter` / `tol` 两个参数(原来 Parameters 里只有 `transition`)、补 `Raises`,
写清 `tol` 是**相邻差口径**(实测慢混合链 `tol=1e-10` → 实际误差 `4.6e-9`,被 `1/(1-|λ2|)` 放大);
③ 修正 Notes 里"周期链会不收敛"的说法(可约链的实情是"平稳分布不唯一、迭代法给离初值最近的那个");
④ 失败文案 `失败: ` → `失败:`(与文件内其它函数统一)
- **测试:+8 条、摘掉 1 条 skip**(此前该函数只有 1 条挂着的 skip)—— 2 状态 `[2/3, 1/3]` + `converged` / `iterations`、
3 状态与特征分解对拍、`data` 是分布(和为 1 / 非负 / `float64`)、慢混合链迭代次数 > 100、
`np.int64` 的 `max_iter`、非法转移矩阵、`max_iter=0` / `tol=0`、docstring Example 真能跑
- **第二轮(2026-10-09,用户改定)**:补非负校验(① 办结)、`params` 记 `transition`(⑥ 办结)、
`success=converged`(选口径 B,④ 办结)、`tol` 拆成 `TypeError` / `ValueError` 且先判 `bool`(② 办结);
Codex 顺手修文案(`max_iter <= 0` 那条写成 "tol 必须是正数")与 `异常: ` → `异常:`,
并补 2 条测试(负元素矩阵、`max_iter=3` 未收敛走 `success=False`)
- **第三轮(2026-10-10,用户改定)**:非负校验带容差 `-1e-12`(⑦ 办结);`max_iter` 类型错改抛 `TypeError`(⑤ 办结);
`tol` 加 `np.isnan` 拦截(③ 办结);非方阵改抛 `TypeError`
- ⏳ **只剩 2 条**(⑧ 未收敛带 `data`、非方阵的 `TypeError`/`ValueError` 与 `markov_simulate` 不一致)见"下一步"(决策 57)
- [x] `solve/simulate.py` 的 `mm1_metrics`(**L1,S 档,算法由用户本人写**,2026-10-09)
- 核心:闭式解 `ρ = λ/μ`、`L = ρ/(1-ρ)`、`Lq = ρ²/(1-ρ)`、`W = 1/(μ-λ)`、`Wq = W - 1/μ`;
`λ >= μ` 走"系统不稳定"分支返回 `success=False`
- **Codex 审查结论:公式全对**(探针实测 3 组参数 `(2,3)` / `(1,4)` / `(0.5,1.0)` 与闭式公式逐位一致,
`W = Wq + 1/μ` 恒等式也成立)
- **Codex 修了一处边界 bug(决策 31,纯 bug)**:稳定性判断写成 `service_rate < arrival_rate`,
而 docstring 与既有测试的口径都是 `λ >= μ` —— 于是 `λ == μ` 掉进 `try`、`rho == 1.0` →
`ZeroDivisionError` 被 `except` 吞成 `success=False`(结果碰巧对,message 却是"计算失败:ZeroDivisionError")。
两支的可观测输出完全相同(`success=False` / `metrics={}` / `data=None`),改 `<=` 只换 message,故直接修并加回归测试
- **Codex 只碰工程侧**:① ruff 6 条(5 处 `W293` + `F541` f-string 无占位符 + `W292` 文件末尾无换行);
② 文案:`service_rate` 的报错写成 "tol"、`max_iter` 的也写成 "tol"(同一轮里 3 处参数名抄错)、半角逗号 → 全角;
③ `call_params={...}` → `call_params = {...}`;④ docstring 补 `Raises` + 说明 `data` 与 `metrics` 同值
- **测试:+6 条、摘掉 2 条 skip**(此前只有 2 条挂着的 skip)—— 闭式公式逐项对拍(含 `W = Wq + 1/μ`)、
`λ == μ` 与 `λ > μ` 都判失败且带"不稳定"文案、入口(0 / 负数 / `bool` / 非数值)、
`λ == μ` 不能靠 `ZeroDivisionError` 落到失败分支、docstring Example
- **第三轮(2026-10-10,用户改定)**:`arrival_rate` 放开到"非负"(`λ = 0` 空系统合法,① 办结)、
两个速率都收 numpy 标量(`np.number`,② 办结)、不稳定分支**保留 `rho`** 并写进 message(④ 办结)、
`data=metrics.copy()` 去掉同一个 dict 对象的别名(③ 部分办结:仍是 dict,只是不再别名)
—— Codex 同步 docstring 与 4 条测试
- ⏳ **只剩 1 条**(`data` 该不该是 dict)见"下一步 → mm1_metrics 待你拍板"(决策 58)
- [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**)
### 验证结果
| 检查 | venv | conda `mcm-toolkit` |
|---|---|---|
| `pytest` | 见下(单列) | **233 passed, 25 skipped(0 xfail)** |
| `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
| `ruff check` | All checks passed | **All checks passed**(0 error) |
| `scripts/doctor.ps1` | 跑通 | — |
| SALib / pymoo / pyright | 缺失 | 已装 |
- `pyright`(分析目标 = 工作区 `.venv`):**0 errors, 0 warnings**
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
`docs/04-environment.md` 坑 7)
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
- **258 条用例:233 passed / 25 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 64、`test_viz.py` 8、`test_decision.py` 7、
`test_stats.py` 53、`test_models.py` 31(第九轮 5 → 23、第十轮 23 → 31)
- **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正
- 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环
- `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz`
- 28 条 skip 是等实现的算法(`train_test_split`、`anova`、`fit_distribution`、`knapsack_dp`、
`linear_program`、`simulated_annealing` 等);
`monte_carlo_probability` / `monte_carlo_integral` / `markov_steady_state` / `mm1_metrics` 已落地,分别摘掉 2 / 1 / 1 / 2 条 skip;`markov_simulate` 新增的 9 条全部实跑;
`TestValidate` 的 4 条仍挂着 —— `check_*` 还是 `NotImplementedError`,但它们实现时应当直接复用 `as_array`
- **本轮回归(已修)**:`transform.py` 上一版有 3 处真 bug —— 修前 **7 failed / 75 passed**,
且 pyright 在同一处报 `reportArgumentType`。**不是静态误报**:pyright CLI 复现 + 测试真红。
结论:见到红线先跑 CLI 与 `pytest`,**不许直接挂 `# type: ignore`**(见个人易错点 10)
- 三个 `.ps1` 通过 PowerShell AST 语法解析
### ⚠️ 需要注意 / 遗留
- [x] ~~两套环境版本不一致~~ → **已解决**:base 升级到 2026.07 后,
numpy/scipy/pandas/matplotlib/sklearn/statsmodels/networkx/sympy 版本完全一致,
只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。
- [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。
note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。
- [x] L0 完成 **12/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
`detect_outliers`、`normalize`、`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`、
`monte_carlo`(用户写,2026-10-04));另 L1 的 `confidence_interval` / `t_test` /
`monte_carlo_probability` / `monte_carlo_integral` 已提前落地;
**L0 已全部完成**(`save_figure` 于 2026-10-18 落地),详情见"下一步"第十一轮
- [ ] 本机 `exec_command` 在沙箱模式下**完全不可用**
(`helper_unknown_error: setup refresh had errors`),只有提权
(`require_escalated`)模式可用。沙箱模式只允许写 `D:\MCM&ICM_Toolkit`。
- [ ] **副作用**:沙箱通道(Node 子进程)以 `CodexSandboxOffline` 账户运行,
它写出的文件 Owner 是沙箱账户而非 `guzhu`。
实测 `guzhu` 对这些文件仍有 Modify 权限(继承自 `Authenticated Users`),
读写不受影响;但 git 的 **dubious ownership** 检查会因此误报,
所以 `.git` 必须由本人账户创建(见决策 17)。
若以后再用沙箱通道写文件,注意同一问题。
## 决策记录
| # | 决策 | 理由 |
|---|---|---|
| 1 | 包数从 14 砍到 7 | `regression`/`ml` 重复;`optimization`/`simulation`/`sensitivity` 同族;`dynamics`/`network` 低频 → 降级为模块 |
| 2 | 只保留 1 个 `Result` | 第一版没有任何函数需要区分 5 种结果类型,属过早分类 |
| 3 | 函数清单从 ~250 砍到 ~40 | 4 天赛程的工具,不是 6 个月的框架;没在真题里用过三次的不写 |
| 4 | 删掉 `maximize` / 重复的 `normalize` / 13 个 `plot_*` 等 | 同义或一行转发;`plot(kind=...)` 替代 13 个函数 |
| 5 | 文档从"每函数 10 项"降到 2 档 | 10 项文档的写作成本是实现的 20 倍,是最可能导致项目停滞的一条 |
| 6 | 补上原文缺失的**论文产出链路** `save_table` / `mcm_style` | 美赛是论文比赛;原文只给了一个 `save_figure` |
| 7 | 补上**永久不做清单** | 原文用"建议不要",防不住膨胀;写成硬规则 |
| 8 | venv 用 `--system-site-packages` | 10.5 MB、离线可用;作为轻量开发与断网兜底 |
| 9 | conda 环境用**精准包列表**而非 `--clone base` | 实测 `--clone` 会重放 base 全套包并逐个下载(连 pandoc 都下),远慢于精准列表 |
| 10 | 生成的是**签名 + docstring + NotImplementedError** | 用户要自己读代码学算法;骨架定接口,实现留给学习过程 |
| 11 | `.ps1` 写成**纯 ASCII**(英文注释/输出),而非中文 | Windows PowerShell 5.1 无 BOM 时按 GBK 解析 `.ps1`,中文直接导致语法错误;保留中文就必须加 BOM,违反 AGENTS.md |
| 12 | `.ps1` 用 LF 而非 CRLF | 遵守 AGENTS.md「shell 脚本 UTF-8 无 BOM + LF」;PS 5.1 对 LF 无问题 |
| 13 | `chatGPT_Suggestion.md` 保持原样 | 用户手写的输入文件,不属于本次产物,不做无谓改动 |
| 14 | base 升级用 `conda update -n base --all`,而不是装新 conda | 计划干净(365 更新 / 0 删除 / 0 冲突),且能保留既有的 `lingbot-map` 环境 |
| 15 | 项目环境 numpy **降**到 base 的 2.4.6 | 升 base 的 numpy 会连带把 BLAS 从 MKL 换成 OpenBLAS;宁可降一个 minor,也不动整套线性代数后端 |
| 16 | 新增 `.gitattributes` 强制 LF | 仓库要给队友在 Windows/WSL/Linux 上 clone,不固定行尾会产生"整文件都变了"的假 diff |
| 17 | `.git` 由**本人账户 guzhu** 重建,不做 `safe.directory` 豁免 | 沙箱通道建的 `.git` Owner 是 `CodexSandboxOffline`,git 会报 dubious ownership。加全局豁免是把问题藏起来;直接以本人账户重建,仓库和远程(Gitea)身份一致 |
| 18 | `core.autocrlf=false`(仓库级) | 配合 `.gitattributes` 的 LF 策略,避免 Windows 上 checkout 又转成 CRLF |
| 19 | Gitea 的 SSH **走 Tailscale 内网**,不在公网开 22 | 实测公网 `8.145.36.108:22` 超时(服务器侧过滤,本机出站 22 没被封:github.com:22 可通),而 Tailscale `100.115.86.48:22` 可达且 `git@` 认证直接过 Gitea。remote 里仍写域名,域名→内网 IP 的映射放在 `~/.ssh/config`,换机只改一处 |
| 20 | 首次推送用 `--force-with-lease` **覆盖** Gitea 的 `Initial commit` | 远端那个提交是建仓时自动初始化产生的,与本地无共同祖先,普通 push 必被拒。推送前已把其中的 LICENSE 与手写简介并入本地 `README.md`,原提交仍留在本机对象库(`1c683cf`) |
| 21 | `Result.__repr__` **不打印 `data` 内容**,只给类型与规模 | 模拟轨迹动辄十万个数,repr 一旦刷屏,traceback / 调试器 / notebook 输出就全废了。要看数据请直接取 `result.data` |
| 22 | `Result.summary()` 的行序 = 字典插入顺序,不排序 | 比赛时"想先看哪个指标"自己决定键的插入顺序即可,工具不该替人排序 |
| 23 | `set_seed` 内部**允许**调用 `np.random.seed`(带 noqa) | sklearn / statsmodels 默认走 numpy 旧全局状态,只记自己的 `_GLOBAL_SEED` 覆盖不到它们,"提交能重跑"就是假的。旧接口在这一个点上不是"取随机数"而是"设种子",性质不同 |
| 24 | `get_rng(None)` **复用同一个模块级生成器**(会推进),不每次新建 | **用户本人提出**,取代 Codex 原来"每次新建 `default_rng(全局种子)`"的写法——那样两个都省略 `random_state` 的函数会抽到完全相同的数。共享单例既保证重跑一致,又让流自然推进 |
| 25 | `src/` 下的实现默认由**用户本人**写,Codex 只改 `tests/` 与 `docs/` | 目标是"一边学一边自己写"。曾出现 Codex 直接落盘 `random.py`、与用户编辑器缓冲区冲突导致保存失败。需要代写时用户会明说 |
| 26 | 未实现的算法用**逐条** `@pytest.mark.skip`,禁用模块级 `pytestmark` | 模块级 skip 会把同文件里**已经写好**的函数一起跳过。本次 `test_data.py` 就这样让 `load_table` 零覆盖,直到有人手动数才被发现 |
| 30 | 已知待修的契约用 `@pytest.mark.xfail(strict=False)`,不用 skip | skip 会让"契约还没写"和"写了但实现有问题"混在一起,5 条 skip 曾把 `clean.py` 的 bug 全藏住。xfail 照样进报告,修好后自动变 XPASS,是"修好了"的信号,而且 XPASS 不失败,不会卡住提交 |
| 29 | 静态分析器钉死到工作区 `.venv`,并把 base 的 site-packages 用 `.pth` 链进去 | pyright / Pylance **不认** `pyvenv.cfg` 的 `include-system-site-packages`,`.venv` 自己只有 `pip`;不钉的话它们会去 PATH 上找 python,本机 PATH 上那个是 Codex 运行时的 3.14 shim,没有科学栈,于是报"无法解析导入"。`.pth` 是唯一被 pyright 认的转发手段 |
| 28 | 不启用 ruff 的 preview 规则集 | `E302`(函数间要 2 个空行)在 ruff 0.16 里是 preview 规则,`select = ["E"]` 默认**不包含**它——所以少一个空行 ruff 照样报 "All checks passed"。行数空行得自己盯,别指望 lint |
| 34 | 常量列(`max == min`)不定区间也不崩:`normalize` 压到 `feature_range` 下界,`standardize` 给 0 | 除零必须有个确定的答案,不能出 nan / inf。给 0 与 `sklearn` 的 `MinMaxScaler` / `StandardScaler` 一致(sklearn 把常量列的 scale 设为 1,结果就是 0),换库不换结论。行为已由 `test_normalize_constant_column_stays_finite` 与 `test_standardize_constant_column_stays_finite` 钉住 |
| 33 | `detect_outliers` 只接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`,`dict` 一律 `TypeError` | §4.7 要求非法类型抛 `TypeError` 而非静默兜底。`dict` 以前能"碰巧"被 `pd.DataFrame()` 转成表,但那不是文档承诺的形状,静默接受会让调用方以为传什么都行;`tuple` 则补进文档与 `is_ld_array`,与 `list` 同等对待 |
| 32 | `detect_outliers` 对非数值列恒返回 `False`(既不跳过也不报错) | **用户本人在两条路里选的**。跳过会让 docstring 里"与原数据同形"变成谎话(形状对不上,没法按位置对回原表);报错则让一张带 `city` 列的真题表直接不可用。`False` 的读法是"这一列没有离群点",对文本列是最不坏的解释,也让整表能一次跑完 |
| 31 | Codex 可**直接改** `src/` 的范围:纯 bug(不改接口与语义)、格式、死代码 | 决策 25 说实现归用户,但"能跑"是底线:`winsorize(pd.Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩、测试变红,这种必须当场修。凡是要**选一个语义**的(文本列返回什么、mode 要不要就地改)一律留给用户本人,Codex 只把选项和影响列清楚 |
| 27 | base 里的 zstandard 残骸目录**改名禁用**,不删除、不重装 | 删掉不可逆;`pip install zstandard` 会给 base 塞一个 conda 记录里根本没有的包,下次 `conda update --all` 又要重新判断。改名后 `import` 变回正常 `ModuleNotFoundError`,pandas 自动跳过 |
| 35 | `log_transform` 的显式 `offset` 按**定义域前提**校验:`min(x) + offset > 0` 才放行 |
| 36 | `describe` 的返回表**不转置**:行 = 指标、列 = 变量 |
| 37 | 非数值列的处置**按输出契约分**,不按包分:`describe` / `zscore` 直接丢掉;`detect_outliers` 保留列、掩码恒 `False` |
两族函数的产出物不一样:`describe` / `zscore` 吐的是**数值结果表**,文本列塞不进去,丢掉才能得到一张干净、可直接贴论文的表;`detect_outliers` 吐的是**与原表同形的掩码**,必须能按位置对回原表,丢列就废了。"不统一"不是前后矛盾,是契约不同。用户 2026-10-03 拍板:丢掉更好 |
| 38 | `coefficient_of_variation` 用 `σ / |mean|`(**用户本人改的**) |
均值为负时 `σ/μ` 会给出负值,容易被读成"波动更小",其实只是符号跟着均值走。取绝对值后 CV 恒非负。可比性仍只在均值远离 0 的比率尺度数据上成立(已写进 docstring 的 Notes) |
| 39 | 工作约定:**语义 / 非法输入口径由用户拍板,Codex 只列选项与影响、不代选**;**ruff、`STATUS.md`、`CHANGELOG.md`、`tests/`、docstring 这类与算法无关的工程事务,Codex 顺手补掉** |
| 40 | scipy 的检验结果统一走 `_pvalue(result: Any) -> float` 助手取 p 值,不逐处挂 `# type: ignore` |
| 41 | `correlation_matrix` **只返回数值矩阵**;星标表拆 `correlation_table`、p 值拆 `correlation_pvalues` |
用户 2026-10-03 选方案 A。数值矩阵能做的事(挑高相关对、喂聚类 / PCA、画热力图)字符串表都做不了,而反过来随时能格式化 —— 展示需求属于**输出端**,不该侵占返回值;一旦返回字符串,数值信息不可逆地丢失。三个函数共用 `_check_corr_args` 守卫与成对删缺失口径,`correlation_table` 在**最外层**调另外两个(计算型当主、展示型当辅)。见易错点 14 |
| 42 | `confidence_interval` **只有 `proportion` 夹到 `[0, 1]`**;`normal` / `t` 是**均值**区间,一律不夹 |
原码把三种 method 的区间**无条件**夹进 `[0, 1]`,且 `min` / `max` 参数写反(见易错点 15),实测把身高均值区间下界压成 `0.0`。均值区间的量纲本来就不在 `[0, 1]` 里;只有比例 `p ± z·√(p(1−p)/n)` 的边界才有意义。**Codex 代改、待用户确认** —— 若原意是"什么区间都夹",改回 `_clip` 一行即可 |
| 43 | scipy 检验结果的**统计量**也走 `_statistic(result: Any) -> float` 助手,与决策 40 的 `_pvalue` 成对 |
`TtestResult` 与 `PearsonRResult` 同源(`_make_tuple_bunch` 运行时拼类),存根里 `.statistic` / `.pvalue` 都没有。用户报的「307 / 308 行报错」就是这两处,**不是真 bug**(探针实测取值正常)。见 `docs/04-environment.md` 坑 10 |
| 44 | 所有收数值的函数**统一从 `core.validate.as_array` 进**;**还原不代做**,调用方按返回的 `kind` 自己手写 |
用户 2026-10-03 提"每个函数都抄一遍入口/校验/交换太麻烦"。能统一的只有**入口**:出口要求"输出与输入同形",而 `describe` / `correlation_*` / `confidence_interval` / `t_test` 天生不同形,硬套会重演易错点 14。用户明确"不要三个函数""转换回去自己手打",所以 `as_array` 只返回 `(arr, kind)`,`ArraySpec` / `restore` 都不做。`numeric_only` 默认 `True`(用户拍的板),但**按输出契约选**:同形函数必须显式 `False` |
| 45 | `as_array` **不删缺失值**;容器类型/内容转换失败**统一抛 `TypeError`** |
删缺失是**语义**(`df.corr` 成对删、`describe` 整列统计、`paired=True` 的 t 检验必须成对删),在入口层删掉就把行对应关系永久丢了 —— 这正是 `t_test` 待拍板的口径之一。错误口径统一为 `TypeError` 是**Codex 代选、待用户确认**:`normalize(含文本列的表)` 原先由 numpy 抛 `ValueError`,现在是 `TypeError`(内容转不成 float);`describe` 的 `weights` 同类情况目前仍抛 `ValueError`(决策 39 说不统一就列出来) |
| 46 | `bootstrap_ci` 的随机数**无条件走 `core.get_rng`**;`statistic_name` 用 `getattr(f, "__name__", None)` 再判 `"<lambda>"` |
原码 `random_state=None` 时另开 `np.random.default_rng()`,等于绕开决策 24(`set_seed` 对它无效,区间不可复现)。**Codex 代改**:`get_rng(None)` 本来就是设计好的"复用模块级生成器且推进",直接传下去即可。`statistic_name` 的 `else "custom"` 是死代码 —— lambda 也有 `__name__`(值恒为 `"<lambda>"`),实测拿不到 `"custom"`,见易错点 17 |
`PearsonRResult` / `SignificanceResult` 是 `_make_tuple_bunch` 在**运行时**拼出来的类,存根里没有 `pvalue` 字段:解包会退化成 `object`、属性访问会报"未知"。运行时完全正常(实测三个函数返回的都是 `np.float64`)。收成 `Any` 是**一处**收窄,调用点仍然干净;`# type: ignore` 要挂在每一处,且会连带关掉该行后面的检查。见 `docs/04-environment.md` 坑 10 |
用户 2026-10-03 明确:没考虑到的非法情况"告诉我我自己改"——因为那是设计决策,得由写实现的人形成判断;而 lint / 进度板 / 测试这类机械活再由用户动手只是浪费。这条与决策 25 / 31 配套:25 定"谁写 src",31 定"Codex 可改的 bug 范围",39 定"非算法事务的默认归口" |
docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向,实现里多做的 `.T` 是净负担 —— 且不是"风格不同"那么轻:转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名零交集,pandas **不报错**、把四行整行填成 NaN。方向约定以"能按列名对齐"为准,见易错点 11 |
原码写的是 `if offset > 0: raise`,把唯一合法的用例(正 offset 正好抬定义域)全拒了,报错文案却是"offset 必须大于 0",自相矛盾。改成校验真正的前提:`log` 要求自变量取正。`offset=0` 对全正数据合法(`offset=None` 的自动分支本来就取 0)。**Codex 代选、待用户确认** —— 若原意是"只收正 offset",改回即可 |
| 47 | **算法一律由用户本人写;工程由 Codex 写**(用户 2026-10-03 明确) |
用户原话:"记住算法的东西一定要我来写,以后就算我说让你来写你也不要帮我写,工程我觉得我还可以,你写我看吧"。
这条比决策 25 更硬:25 说"默认归用户",47 说"**即便用户改口也不代写**"——目的是保住"一边学一边自己写"的学习路径。
工程侧(入口/校验、`tests/`、lint、文档、卡片、`.gitignore`)反过来由 Codex 直接落盘,用户只读;
分地盘按**文件**划,不按函数划。决策 39 的"语义由用户拍板"继续有效。
| 48 | `monte_carlo` 的四个口径由**用户拍板**:实现传 `size=n`;结果含 NaN 判失败;`std` 用 `ddof=0`;出错仍统一返回 `success=False` |
用户 2026-10-04 直接改在代码里。① 批量大小由实现传(`sampler(rng, size=n, **kwargs)`):调用方少写一次,
代价是**每个 sampler 都必须能接住 `size`**,且 `**kwargs` 里不能再传 `size`(会重复传参)—— 已写进 docstring。
② 结果里只要出现 NaN 就 `success=False`:MC 的 NaN 多来自除零 / 模型发散,静默统计只会得到一列 nan 指标。
③ `np.std` 默认 `ddof=0`,现在显式写明(与 `describe` 那条线的 `ddof=1` 不一致,属已知差异)。
④ 计算期异常仍全部包成 `success=False`(保留 `try/except Exception`);代价是自己写错代码时没有 traceback。
配套 Codex 代改:入口 `np.asarray(..., dtype=float)` —— 布尔结果此前会让 `np.std` 抛错(真 bug,已修)。
**唯一未定**:`ci_low`/`ci_high` 保持"结果的分位数"(现状)还是改成"均值的置信区间"。
| 49 | **这类改动归用户本人**:入口没收紧、少考虑情况、不符合简单原则 —— 用户 2026-10-04 明确"交给我改,不要帮我改" |
用户原话:"以后像这种入口没收紧、少考虑情况、不符合简单原则的情况交给我改,不要帮我改,把这几条经验写入经验文档"。
这是决策 47 的**收窄**:47 把"入口 / 校验重构"划给了 Codex,49 把它收回 —— 凡是**带判断、取舍、设计成分**的,
一律由用户写,Codex 只报告 + 列选项(与决策 39 同一逻辑)。判据一句话:**这处改动要不要"想一下才对"** ——
要,就留给用户;不要(ruff、格式化、docstring 缺节、`tests/`、`STATUS` / `CHANGELOG`、函数卡片)才由 Codex 顺手补。
本轮实例(本该留给用户,Codex 已代改、用户可回退重写):`monte_carlo` 的 `n` 按 §4.7 收严;入口 `np.asarray(..., dtype=float)`。
经验已写入 `docs/dev/personal-pitfalls.md` **第 18 / 19 / 20 条**,并把第 1 条(类型判断只写 `int`)**复发 +1**。
| 50 | `monte_carlo_probability` 的区间由 **Wald 改 Wilson score**;小概率不再判失败(改成 message 警告);`confidence` 记进 `params` |
用户 2026-10-05 按"6 条待办"自行改定。Wilson 在 p 贴近 0 / 1 时不会退化成零宽:实测 p=1、n=1000 从 `(1.0, 1.0)`
变成 `(0.99617, 1.0)`;p=0 从"判失败"变成 `(0.0, 0.00383)`(原 Wald 是 0)。区间口径与
`statsmodels.proportion_confint(method="wilson")` 对齐,已加成对照测试。
**遗留**(2026-10-05 第二轮复核,同日**已清**):~~`event: Callable[..., bool]` 注解与"吃整批、返回数组"不符~~ → 已改 `Callable[..., Any]`;
警告文案把 p≈1 也说成"小概率事件" —— **用户已于同日改掉**(现在两半都覆盖,见 CHANGELOG 本轮)。
| 51 | `monte_carlo_integral` 的 `data` = **n 个样本各自的贡献**(`func(X_i) × 体积`);积分估计值看 `metrics["mean"]` |
用户 2026-10-05 写完实现。Codex 审查只改了一处**纯 bug**(决策 31):体积 `lows - highs` → `highs - lows` ——
奇数维体积为负(1D `x²` 于 `[0,1]` 实测 **-0.3324**、3D `x` 得 **-0.5005**),而 **2D 因两个负号相乘"看着对"**
(+0.2499),把 bug 藏住了;改后实测 0.3324 / 0.5005 / 1.9971 全部正确。docstring 与测试都按上述口径写死。
**留给用户 3 条**:① `result.message` 无条件覆盖(失败被写成"成功");② `bounds` 形状没校验(`[0, 1]` 抛裸 `IndexError`);
③ `n: int` 与全模块的 `int | np.integer` 不一致。
| 52 | `monte_carlo_integral` 的入口口径由**用户第二轮**改定:`message` 只在成功分支改写;`bounds` 必须是非空 `list`/`tuple` 且形状 `(d, 2)`;`n` 收 `int \| np.integer` |
用户 2026-10-05 按 Codex 报的 3 条自行改定。`message` 走 `if result.success:` / `else:`(失败分支保留失败语义并追加追溯信息:
维度 / 体积 / 模拟次数)—— 修掉"`success=False` + 一句成功文案"的自相矛盾。`bounds` 入口两道校验把
`[0, 1]` 从 `IndexError: too many indices` 变成带当前形状的可读 `ValueError`。遗留 2 条新口径:
① `isinstance(bounds, (list, tuple))` 把 `np.array([[0, 1]])` 也拒了,与"尽量传 ndarray"的偏好冲突;
② `if` / `else` 两支里 `name` / `params["bounds"]` / `params["dimension"]` 各写了一遍(易错点 20)。
| 53 | `random_walk` 的默认步长采样器**不能用 `rng.integers(..., dtype=float)`**(numpy 直接抛 `TypeError`);`step_sampler` 收到的 `size` 是**二维** `(n_paths, n_steps)` |
用户 2026-10-05 写完第一版。默认采样器原写 `rng.integers(0, 2, size=size, dtype=float) * 2.0 - 1.0` ——
`Generator.integers` 不收浮点 dtype(`TypeError: Unsupported dtype dtype('float64') for integers`),
于是**整个默认路径 100% 失败**,连 docstring 的 Examples 都跑不通;Codex 按决策 31 改成 `rng.integers(0, 2, size=size) * 2 - 1`
(交给后面的 `np.asarray(..., dtype=float)` 转类型),实测形状 `(5, 101)`、步长恒 ±1、同种子可复现。
`step_sampler` 的 `size` 是 `(n_paths, n_steps)` 而不是 `monte_carlo` 那样的一个整数 —— 契约已写进 docstring,
**遗留 4 条待办**见"下一步"(`TypeError`/`ValueError` 分工、`steps` 形状校验、NaN 口径、`params` 缺 `random_state`)。
| 54 | `random_walk` 第二轮口径由**用户**改定:`n_steps`/`n_paths` 类型错抛 `TypeError`、取值错抛 `ValueError`;加形状与 NaN 两条校验;默认步长改 `rng.choice([-1.0, 1.0])`;`params` 补 `random_state`;`metrics` 改 `mean_terminal`/`std_terminal` |
用户 2026-10-05 自行改定,Codex 只修了一处**纯 bug**:`call_params` 定义在 `try` 内、却被 `except` 引用,
导致**所有失败路径**抛 `UnboundLocalError`(实测 5/5 失败输入全中)。按原意前移到 `try` 之前,未改任何口径。
**遗留 3 条**:① `start` 的入口类型校验被删(`start="a"` 从 `TypeError` 变成 `success=False`),是有意还是顺手删的?
② 签名 `n_steps: int` / `n_paths: int` 与运行期收 `np.int64` 不一致(§4.7;`monte_carlo_integral` 的 `n` 已放宽);
③ `n_paths == n_steps` 时,返回转置形状的采样器仍会静默通过(形状校验天生发现不了,见易错点 25)。
| 55 | `step_sampler` 的"方阵歧义"按**方案 B** 解决:`size` 改成整数 `n_paths * n_steps`、必须返回**一维**数组,内部 `reshape(n_paths, n_steps)` |
形状校验(决策 54)对"方阵"天生失效 —— 转置 `(n_steps, n_paths)` 正好等于目标 `(n_paths, n_steps)`。
**用户 2026-10-05 拍板选 B**(Codex 代改,用户明确要求)。转置 `(n_steps, n_paths)` 与目标 `(n_paths, n_steps)` 在方阵时形状相同,
校验从形状上无法区分 —— 根源是**契约本身有歧义**(一个 tuple 尺寸给了两种朝向)。改成整数尺寸 + **要求一维**后,
二维返回一律判失败,转置问题从根上消失。顺带与 `monte_carlo` 的 `sampler` 契约(决策 48 的 `size=n`)统一:
实测给 `monte_carlo` 写的采样器可以直接丢给 `random_walk`。**关键点:不要用 `.ravel()` 救场** —— 那会把转置的二维摊平,
歧义原样回来。**代价(已写进 docstring)**:`step_sampler` 不能再返回二维,步与路径的对应关系是**行主序**。
探针实测:默认采样器 `(5, 101)` 正常;后四种(转置二维 / 方阵二维 / 长度 +1 / `(N, 1)`)一律判失败。
| 56 | `markov_simulate` 的 `initial` 语义**待用户拍板**(Codex 只报选项,未代改,决策 49) |
现状用 `isinstance(initial, int)` 区分"标量状态 / 状态数组",两条分支的口径互相打架:标量分支按**路径数**铺满
(`np.full(n_paths, initial)`),数组分支却按**状态数**校验长度(`len(initial) != s` 直接报错)——
于是数组分支只有 `n_paths == s` 时才可能跑通;而 `paths[:, 0] = current_states` 又写在 `try` **外面**,
长度不匹配时**裸抛** `ValueError`(本该 `success=False`,同易错点 26 一族)。另有三个口子:
`np.int64(0)` 掉进数组分支、被 `as_array` 拒(与 `steps`/`n_paths` 收 `np.integer` 不一致,易错点 1 复发);
`True` 因 `isinstance(True, int)` 为真被当状态 1 **静默放行**;越界状态(如 `initial=5`、`s=2`)只在
第一轮索引时才炸、被 `except` 吞成 `success=False`。
**2026-10-09 更新**:② 已办结(用户补了 `np.any(arr < 0)`,含负元素的行随机矩阵现在入口直接报错)。
**2026-10-10 更新**:③ 办结(`paths` 的构造与首列赋值挪进 `try`,长度不匹配/越界不再裸抛);
④ 办结(越界现在被吞成 `success=False`,但仍没在入口拦);⑤ 办结(`std_terminal` → `terminal_distribution`);
② 升级为带容差 `-1e-12`;`params` 的 `n_steps` → `steps`、`initial` 存成可 JSON 化的值。
**只剩 ①**:`bool` 仍被当状态 1(`np.integer` 已收),以及"数组按状态数 vs 按路径数"两条口径的矛盾(A / B / C)。
| 57 | `markov_steady_state` 的口径:**4 条已办、3 条 + 2 个新口子仍待拍板**(Codex 只报选项,未代改,决策 49) |
**2026-10-09 用户第二轮办结**:① 补 `np.any(arr < 0)`(含负元素的行随机矩阵现在入口即报错,原先会溢出成 `nan`
却报 `success=True`);② `tol` 拆成 `TypeError`(类型 + `bool`)与 `ValueError`(取值);
④ **选口径 B**:`converged=False` → `success=False`;⑥ `params` 补 `transition`(`arr.tolist()`,可 JSON 化)。
**仍待拍板**:③ `tol=np.nan`(`nan <= 0` 为假,穿过校验白跑 §max_iter 次迭代);
⑤ `max_iter` 的类型错仍抛 `ValueError`(与决策 54 的"类型错 `TypeError`"不一致);
⑦ **两把尺子宽严不一**——非负校验用精确 `arr < 0`,同一个函数里的行和校验却用宽容的 `np.allclose`,
实测 `[[1.0, -1e-16], [0.5, 0.5]]`(行和都是 1)会被判非法,建议改成 `< -1e-12` 这类容差;
⑧ 未收敛时 `success=False` 但 `data` 仍是未收敛向量(文件内唯一"失败却带 `data`"的分支)—— 保持还是 `data=None`?
**2026-10-10 更新**:③(`tol` 拦 NaN)、⑤(`max_iter` 类型错 → `TypeError`)、⑦(非负校验带容差)办结。
**只剩 2 条**:⑧(上面那条),以及新出现的"非方阵抛 `TypeError`,而 `markov_simulate` 的同一条校验抛 `ValueError`"不一致。
| 58 | `mm1_metrics` 的稳定性分界由 Codex 按**决策 31** 直接修(`<` → `<=`),另有 4 条口径待用户拍板 |
docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `service_rate < arrival_rate` —— 于是 `λ == μ`
掉进 `try`、`rho == 1.0` → `ZeroDivisionError`,被 `except` 吞成 `success=False`
(碰巧对,但 message 成了"计算失败:ZeroDivisionError - float division by zero")。两支的可观测输出完全相同
(`success=False` / `metrics={}` / `data=None`),改 `<=` 只换 message,故直接修 + 加回归测试。
**待拍板**:① `λ = 0` 现在被 `ValueError` 拒(数学上是合法退化解:空系统 `L = W = 0`);
② `np.int64` / `np.float32` 全被 `TypeError` 拒(文件内 `random_walk` 的 `start` 收 `np.number`、
整数参数收 `np.integer`,§4.7 有共识);③ `data=metrics` —— **同一个 dict 对象**(`r.data is r.metrics`),
且成功分支给 dict、失败分支给 `None`,与文件内"`data` 是数组"的习惯不同;
④ 失败分支的 `metrics={}` 会丢掉 `rho` 等已算出的量(可选保留)。
**2026-10-10 更新**:① ② ④ 办结(`λ = 0` 放开为合法空系统、两个速率收 `np.number`、失败分支报 `rho`);
③ 只办了一半(`data=metrics.copy()` 去掉了同一个 dict 对象的别名,但 `data` 仍是 dict、失败时仍是 `None`)—— 只剩这一条。
| 59 | `models/metrics.py` 开工:6 个指标 + 共享入口 `verify_yt_yp` **由用户写**,Codex 只做工程侧审查(决策 47 的延续) |
本轮把 `metrics.py` 的 L1 `TODO` 转成实现(`compare_models` 仍 `NotImplementedError`)。六个指标共用一个入口
`verify_yt_yp`:`as_array(ndim=1)` → 形状不一致 / 长度 0 / 含 `NaN` 一律 `ValueError`(与决策 48 的"NaN 判失败"同口径)。
Codex 侧(决策 47 授权范围):补 7 处 docstring(`Parameters` / `Returns` / `Raises`)、统一报错文案为"参数名 + 实际值"、
清 6 条 `F541` + 3 条 `W293`、补空行;`tests/test_models.py` 两条指标测试转正 + 新增 18 条。
**用户侧拍板并亲手改**:`r2_score` 的 `ss_tol` 基准从 `np.mean(yp)` 改成 `np.mean(yt)`(真 bug,见决策 60 与易错点 31),
顺带把无意义的 `ss_tol` 改名 `ss_tot`。Codex 探针实测:修前 0.8803827751196172、修后 0.875 = `sklearn.metrics.r2_score` 一致。
**另有 4 条口径仍待拍板**:`mape` 遇负 `y_true`(现在返回无意义的负数)、只拦 `NaN` 不拦 `inf`、
`rmse(1.0, 1.0)` 标量被当 1 个样本、`compare_models` 的键名 `"r2"` 与函数名 `r2_score` 对不上。
| 60 | `smape` 的零除告警按**决策 31** 由 Codex 直接修(`np.where` → `np.divide(..., where=)`),数值结果不变 |
原式 `np.where(denom == 0, 0.0, 2 * |yt - yp| / denom)` 的**结果是对的**(分母 0 记 0),但 `np.where` 两条分支都会被求值,
分母为 0 时仍触发 `RuntimeWarning: invalid value encountered in divide`;而 `pyproject.toml` 开了
`filterwarnings = ["error::RuntimeWarning"]` —— 于是任何"含 0 的 smape"测试都会直接红(探针复现)。
改成 `np.divide(..., out=np.zeros_like(denom), where=denom != 0.0)` 后告警消失、数值与口径完全不变,
并补了一条测试把"不许告警"钉住(`test_smape_is_silent_when_denominator_is_zero`)。
**判据**:只是防告警、不改语义 → 工程侧;如果是"改公式"(如决策 59 的 r2),一律留给用户(决策 49)。
| 61 | `models/metrics.py` 的三条入口口径由**用户第三轮改定**(`Inf` / 至少 2 个样本 / `mape` 拦非正数),Codex 只同步 docstring + 测试 |
前一轮报的 4 条待拍板里,用户改了前三条:① `verify_yt_yp` 增加 `np.isinf(yt).any() or np.isinf(yp).any()`
(只拦 `NaN` 时 `rmse([1.0, inf], ...)` 会安静地返回 `inf`);② 增加"至少 2 个样本"(1 个点的误差指标没有统计意义,
`r2_score` 更是直接无定义),**标量也因此被拦住**(不再被当成 1 个样本悄悄算);③ `mape` 从"只拦 0"改成
`np.any(yt <= 0)`(原来的 `mean(|yt - yp| / yt)` 在 `yt < 0` 时返回**负数**,实测 `-1.0`)。
Codex 侧:把这三条口径写进 7 处 `Raises`(**又是易错点 30**:口径一改、六处 docstring 立刻过期,本轮`复发 1`);
`test_mape_rejects_zero_in_y_true` 的 `match` 文案随新消息改为"非正数",并新增 `Inf` / 至少 2 个样本两条测试。
**遗留小问题(Codex 没动)**:`verify_yt_yp` 里 `yt.ndim == 0` 那一支是**死代码** ——
`as_array(..., ndim=1)` 必然 `reshape(-1)`,`ndim` 恒为 1,可整段塌成 `if yt.shape[0] <= 1:`(真正的判定条件)。
| 62 | `compare_models` 由 Codex 按 **A 档**落地,指标名口径选**别名映射**(用户 2026-10-12 拍板) |
前一轮问的是"`metrics=("rmse","mae","r2")` 里的 `"r2"` 与函数名 `r2_score` 对不上,用哪个"。用户选**两个都收**,
所以别名表落在模块级的 `_METRIC_FUNCS`(`"r2"` 与 `"r2_score"` 指同一个函数):**传哪个名字,输出表的列就叫哪个名字**,
两个一起传报 `ValueError`(否则会出两列一模一样的数字)。别名只解决"名字认不认",不改变数值。
实现要点:`models` 必须传**模型对象**(`fit` / `predict`),不是拟合结果;`X` 必须二维(`as_array(ndim=2)` 不替你猜);
分折是**手写 K 折**(`get_rng(random_state).permutation` + `np.array_split`),**不依赖 sklearn** —— 只装了 4 个核心依赖也能跑;
`cv=0` 全量拟合后打分(`cv=1` 报错:要么 0 要么 ≥2);`cv >= 2` 报**折间均值**;每折至少 2 个样本 ⇒ `cv <= n // 2`;
排序方向跟着**第一个指标**走(误差类升序、R² 降序)。`adjusted_r2` **不进表**(还要 `n_features`,`compare_models` 不知道每个模型用了几列)。
测试 23 → 31 条:首列排序(升序 / 降序两条)、别名、坏指标名、坏 `models` / `cv`、每折样本数下限、`random_state` 可复现。
## 下一步
### ⭐ 当前进度与待办(2026-10-18 第十一轮)
**本轮:L0 收官 —— `save_figure` 落地(Codex 写,A 档)。**
- `viz/export.py`:实现 `save_figure`。入口先校验 `fig` / `dpi` / `formats` / `bbox_inches` /
`transparent` / `path`,**全部校验通过后才建目录、写文件**;`formats` 支持大小写归一,
空 / 未知 / 重复格式一律报错;`path` 带图片后缀时自动去后缀(不会写出 `fig1.png.png`)。
- `tests/test_viz.py`:去掉违反决策 26 的模块级 `pytestmark`,改为逐条 skip;
`test_save_figure_writes_png_and_pdf` 不再依赖未实现的 `plot()`,改用 `plt.subplots()` 构造图。
新增 8 条:后缀剥离、自动建目录、格式归一与顺序、单个字符串 / 空 / 未知 / 重复格式、
校验先于副作用、坏 `dpi` / `bbox_inches` / `transparent` / 非 `Figure`。
- 三连:`pytest` **242 passed / 24 skipped**、`ruff` **0**、`pyright` **0**。
**下一步**
1. `models/regression.py` 的 `fit_linear` / `fit_polynomial`(A 档)。**开工前先定返回契约**:
`Result` 没有 `fit/predict/score`,与 `compare_models` 要求 `Model` 协议的入参对不上,
需要定成"`Result.data` 放已拟合模型 + `compare_models` 接受两种入参"之类的桥接方案。
2. `decision/entropy_weights`(S 档,用户写);Codex 先补函数卡片 / 测试骨架。
3. 集中拍板 `STATUS.md` 里挂着的小口径(`t_test` / `as_array` / `bootstrap_ci` / `markov_*` / `mm1` / 死代码)。
### ⭐ 当前进度与待办(2026-10-12 第十轮)
**本轮:你办掉第九轮那 4 条口径里的 3 条,Codex 落地 `compare_models`(别名映射)。**
你改的三条(决策 61):`verify_yt_yp` 拦 `Inf`、拦"只有 1 个样本"、`mape` 从"只拦 0"改成 `yt <= 0`。
三条我都复测过:`rmse([1.0, inf], [1.0, 2.0])` 现在报 `ValueError`;`rmse(1.0, 1.0)` 也报错(标量不再被当成 1 个样本);
`mape([-1.0, 2.0], [1.0, 2.0])` 由返回 `-1.0` 变成报错。
Codex 这轮:① 把三条新口径写进 7 处 `Raises`(口径一改、docstring 就过期 —— **易错点 30 复发 1 次**);
② `compare_models` 按 A 档实现(决策 62),指标名走**别名映射**:`_METRIC_FUNCS` 里 `"r2"` 与 `"r2_score"` 指向同一个函数,
传哪个列名就叫哪个、两个一起传报错;③ 顺手补了 `X` 必须二维、`cv` 白名单(`bool` 不收、`cv=1` 无意义、`cv <= n // 2`)、
`models` 必须是"有 `fit`/`predict` 的对象"等入口;④ 测试 23 → 31 条。
三连:`pytest` **233 passed / 25 skipped(258 收集)**、`ruff` **0**、`pyright` **0**。
**接下来(`models` 的完成标志 `compare_models` 已达成)**
1. `regression.py` 的 `fit_linear` / `fit_polynomial`(A 档薄封装;把 `base.Model` 三件套跑通,
顺带把 `tests/test_models.py` 里那两条**还挂着的 `skip`** 转正)。**注意**:`fit_linear` 落地后要补一条
"`compare_models` + 真实模型"的集成测试 —— 原来那条跳过用例写的是 `compare_models({"linear": fit_linear(X, y)})`,
**把拟合结果当模型传是错的**,本轮已把它改写成用测试桩(`_MeanModel` / `_LinearModel`)。
2. `models` 剩下的 `fit_nonlinear` / `timeseries` / `ml` 按 roadmap 走。
3. 之后转 `decision/entropy_weights`(**S 档,你写**,三个坑:方向 / 量纲 / 零值)。
### ⏳ 第十轮新出现的 1 条小口径(Codex 只报不改,决策 49)
**`verify_yt_yp` 里 `yt.ndim == 0` 那一支是死代码**:`as_array(..., ndim=1)` 内部一定 `ravel()`,`ndim` 恒为 1,
所以 `if yt.ndim == 0 or yt.shape[0] <= 1:` 里前半段永远为假,报错文案里的 `else 1` 也永远走不到。
整段可以塌成 `if yt.shape[0] <= 1:`(行为完全不变,只是少一段"看着像在防什么、其实没防"的代码)。
你决定改不改 —— 不改也能跑,只是以后回头看会多花几秒钟。
### ⭐ 当前进度与待办(2026-10-10 第九轮)
**本轮:`models` 开工,你亲手写了 `models/metrics.py`** —— 6 个回归指标(`rmse` / `mae` / `mape` / `smape` /
`r2_score` / `adjusted_r2`)加一个共享入口 `verify_yt_yp`;`compare_models` 仍是 `NotImplementedError`。
Codex 只碰工程侧:7 处 docstring(补 `Parameters` / `Returns` / `Raises`,把口径写成契约)、报错文案统一成
"参数名 + 实际值"、清 6 条 `F541` + 3 条 `W293` + 空行、`tests/test_models.py` 5 条 → 23 条(两条转正 + 18 条新增)。
**Codex 找到 1 个真 bug(已由你亲手改定)**:`r2_score` 的 `ss_tot` 用了 `np.mean(yp)`,基准必须是 `np.mean(yt)`。
探针实测(`y_true=[1,2,3,4,5]`、`y_pred=[1.5,2.5,2.5,4.5,5.5]`):修前 **0.8803827751196172**、修后 **0.875**,
与 `sklearn.metrics.r2_score` 完全一致。它会连带污染 `adjusted_r2`。**注意 `r2_score(y, y) == 1` 仍成立** ——
完美拟合与常量 y 都会掩盖这类错,所以新增的测试特意用"非完美拟合"的数据(见个人易错点 31)。
**另修一处会真炸的工程问题(决策 60)**:`smape` 的 `np.where` 掩码防不住零除,`filterwarnings=error::RuntimeWarning`
下任何含 0 的测试都会红 → 改 `np.divide(..., where=)`,数值结果不变,并补测试钉住"不许告警"。
三连:`pytest` **224 passed / 26 skipped(250 收集)**、`ruff` **0**、`pyright` **0**。
**接下来(按 roadmap §四,`models` 的完成标志就是 `compare_models`)**
1. `compare_models`(同文件最后一块;`cv=0` 走"全量拟合后打分"分支,返回按第一列指标排序的 `DataFrame`,
依赖 `pd.DataFrame`,顺带定 `metrics` 的键名口径)。
2. `regression.py` 的 `fit_linear` / `fit_polynomial`(把 `models/base.py` 的 `Model` 协议三件套跑通)。
3. 然后转 `decision/entropy_weights`(**S 档,你写**,三个坑:方向 / 量纲 / 零值)。
### ✅ 第九轮那 4 条口径(2026-10-12 全部办结)
1. ~~`mape` 遇负 `y_true` 返回负数~~ → **用户选"拦"**:改成 `np.any(yt <= 0)` 直接报错并点名改用 `smape`
(另一条路是用 `|y_true|` 做分母,没选)。测试文案随之改成 `match="非正数"`。
2. ~~`inf` 不拦~~ → **用户选"跟 `NaN` 一样拦"**:`verify_yt_yp` 增加 `np.isinf(...).any()`(`rmse` 不再安静地返回 `inf`)。
3. ~~标量被当成 1 个样本~~ → **用户选"要求至少 2 个点"**,于是 `rmse(1.0, 1.0)` 现在报 `ValueError`。
注意:这与 `as_array` 白名单里"标量要不要摘掉"是**两个问题**(那条仍未拍板,见下方"其余待办"第 6 条)。
4. ~~`compare_models` 的键名 `"r2"` vs `"r2_score"`~~ → **用户选别名映射**(两个都收),由 Codex 实现,见决策 62。
### ⭐ 当前进度与待办(2026-10-10 第八轮)
**本轮:你按第七轮的清单改了 5 处口径** —— `markov_simulate`(非负校验带容差、收 `np.integer`、赋值挪进 `try`、
`metrics` 换成 `terminal_distribution`)、`markov_steady_state`(容差、`max_iter` 的 `TypeError`、
`tol` 拦 NaN)、`mm1_metrics`(`λ = 0` 放开、收 numpy 标量、失败分支报 `rho`、`data` 去别名)。
**三处算法本体仍然正确**(探针复测:终点分布 `{0: 0.6644, 1: 0.3356}` 对照 `[2/3, 1/3]`;
`λ = 0` 给 `L = Lq = Wq = 0`、`W = 1/μ`;`r.data is r.metrics` 已为 `False`)。
Codex 只同步 docstring + 6 条测试、清 ruff(`B905` 的 `zip` 缺 `strict` + 2 条空白)。
三连:`pytest` **204 passed / 28 skipped**、`ruff` 0、`pyright` 0。
**接下来三件事**
1. 下面只剩 **5 条小口径**,基本都是"一个值 / 一个类型"级别的决定。
2. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。
3. 之后往 `models` / `decision` 走(见下方第八轮备忘)。
### ⏳ 只剩这 5 条口径待你拍板(Codex 只报不改)
**① `markov_simulate` 的 `bool` 漏网**:`isinstance(True, (int, np.integer))` 为真 → `initial=True` 被当成状态 1。
实测现在**不再静默给错答案**(`np.full(n_paths, True)` 让后面 `arr[current_states]` 变成布尔掩码、崩掉后吞成 `success=False`),
但 message 是个看不懂的广播错误 —— 建议入口先判 `bool` → `TypeError`,把易错点 1 的最后一格补上。
**② `markov_simulate` 数组分支的长度口径**(决策 56 的 ①,仍是最大的那条):标量按**路径数**铺满、数组按**状态数**校验,
两条对不上(只有 `n_paths == s` 才通)。A / B / C 三个选项见决策 56。
**③ `markov_steady_state` 的 `data`**:`converged=False` → `success=False`,但 `data` 仍是未收敛向量
(文件内唯一"失败却带 `data`"的分支)。保持(B)还是 `data=None`(C)?
**④ 非方阵的异常类型两处不一致**:`markov_steady_state` 抛 `TypeError`、`markov_simulate` 抛 `ValueError`
(同一条"不是二维方阵"的校验)。numpy 自己对形状问题抛的是 `ValueError`;要统一成哪个?
**⑤ `mm1_metrics` 的 `data`**:现在是 `metrics.copy()`(不再是同一个对象),但仍是 **dict**,失败分支是 `None`。
选项:**A** 保持;**B** `data=None`;**C** `data` 给一个 5 元素数组。
### 📦 第八轮备忘:`models` 与 `decision` 是干什么的
这两个包**目前一行都没实现**(全部 `TODO` + `NotImplementedError`),是 `solve` 之后的两大块。分工完全不同:
| | `models` —— 建模 | `decision` —— 评价决策 |
|---|---|---|
| 回答的问题 | "这条规律是什么?能不能预测?" | "这几个方案哪个好?" |
| 输入 | 特征 `X` + 目标 `y`(有监督)或只有 `X`(聚类/降维) | **决策矩阵**(行 = 方案、列 = 指标),通常无标签 |
| 输出 | 模型对象 + 预测值 + 指标;`compare_models` 出对比表 | 权重向量 + 排序表(`rank`) |
| 主要依赖 | sklearn / statsmodels(**薄封装**) | numpy 手写为主(S 档:熵权、AHP、GM(1,1)、TOPSIS) |
| 美赛场景 | 预测题(人口、销量、气温)、"要不要上复杂模型" | 中文赛 / ICM 的评价题(AHP、TOPSIS、灰色关联、熵权) |
| roadmap 时间盒 | 第 2–3 周 | 第 4 周 |
`models`(19 个函数,5 个文件):`base.py` 已写好 `Model` 协议(`fit / predict / score` 三件套,已实现);
`metrics.py` 是 8 个指标 + `compare_models`(返回 DataFrame,可直接 `save_table(fmt="latex")`);
`regression.py` / `timeseries.py` / `ml.py` 全是"一行转发 + 参数校验 + `Result` 包装"。
注意两处:① `rmse` / `mae` / `r2_score` 的注释写的是 `TODO(L0)`,但 roadmap 的 L0 12 项里没有它们,
它们属于 L1 的 `models` 清单 —— 注释待统一;② `tests/test_models.py` 用的是**模块级** `pytestmark`,
违反决策 26(逐条 skip),开写时要拆开。
`decision`(8 个函数,4 个文件):`weighting.py`(等权 / 熵权 / CRITIC)、`ahp.py`(判断矩阵 + 一致性比率 `CR`)、
`topsis.py`、`grey.py`(灰色关联 + GM(1,1) 预测)。
**使用顺序是硬规矩**(`decision/__init__.py` 里写着):先 `normalize` 无量纲化 → 再声明方向 `benefit_columns`
→ 再 `entropy_weights` / `ahp_weights` → 最后 `topsis`。跳过第一步直接算权重是评价类题最常见的翻车方式。
S 档(要配函数卡片)三个:`entropy_weights` / `ahp_weights` / `gm11_fit_predict`,另有 `topsis` 也标了卡片。
永久不做:`dea_efficiency`(要解一串 LP、低频)、`fuzzy_comprehensive_evaluation`(隶属度函数没有通用形式)。
**建议的开工顺序**:先 `models/metrics.py`(纯函数、最容易、`compare_models` 是"要不要上复杂模型"的裁判,
也是 roadmap §四里 `models` 的完成标志)→ 再 `fit_linear` / `fit_polynomial`(把 `Model` 协议跑通)
→ 然后 `decision` 的 `entropy_weights`(S 档、20 行、三个坑:方向 / 量纲 / 零值)→ `topsis` → `ahp` → `gm11`。
### ✅ random_walk 的 4 条待办(2026-10-05 已办结)
- ~~① 非整数 `n_steps` 抛 `ValueError`~~ → 已拆成两条:类型错 `TypeError`、取值错(`<= 0`)`ValueError`,`n_paths` 同样处理。
- ~~② `steps` 没有形状校验~~ → 已加 `steps.shape != (n_paths, n_steps)` 判失败(实测转置采样器现在能拦住)。
- ~~③ NaN 不判失败~~ → 已加 `np.isnan(steps).any()`,与 `monte_carlo` 的 NaN 口径(决策 48)拉齐。
- ~~④ `params` 少 `random_state` / `metrics` 抄了一遍~~ → 已补 `random_state`;`metrics` 换成 `mean_terminal` / `std_terminal`
(终点位置的均值与标准差,`ddof=0`)—— 这才是"游走"该看的指标,测试里还顺手验了 `std ≈ √t`。
### ✅ random_walk 的口径(2026-10-05 全部办结)
- ~~① `start` 的入口类型校验被删~~ → 已恢复;文案的"必须是整数"改成"必须是数值"(这条校验本来就收 float),
类型名统一 `{type(start).__name__}`。
- ~~② 签名注解比行为窄~~ → 已改成 `int | np.integer`,`np.int64` 的测试补回来。
- ~~③ `n_paths == n_steps` 的方阵歧义~~ → 用户选**方案 B**(决策 55):`size=n_paths * n_steps`(整数)、
要求返回一维、内部 `reshape(n_paths, n_steps)`;二维返回一律判失败。实测转置二维 / 方阵二维 / 长度不符全部拦下,
且 `monte_carlo` 风格的采样器可直接复用。
### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 全部办结)
- ~~① `p <= 1e-3` 判 `success=False`~~ → 改成 `warning_message`,仍返回区间(实测 p=0 → `(0.0, 0.00383)`)。
- ~~② Wald 在 p 靠近 1 时退化~~ → 换 **Wilson**(p=1、n=1000:`(1.0, 1.0)` → `(0.99617, 1.0)`)。
- ~~③ `call_params` 没记 `confidence`~~ → 已加。
- ~~④ `event` / `sampler` 不可调用被吞成 `success=False`~~ → 已加 `callable` 校验,现在抛 `TypeError`。
- ~~⑤ `metrics` 混 `np.float64`~~ → `float()` 统一(实测 4 个 key 全是 `float`)。
- ~~⑥ `n: int`~~ → 已改 `int | np.integer`。
**~~还剩 1 条~~ → 已办(本轮)**
1. ~~`event: Callable[..., bool]` 的注解与行为不符~~ → 已改 `Callable[..., Any]`,与 `sampler` / `simulator` 对齐。
pyright 复测:向量化事件函数不再被拒(原注解会报 `reportArgumentType`)。**决策 50 的遗留清零。**
### ✅ monte_carlo_integral 的 3 条待办(2026-10-05 已办结)
- ~~① `result.message` 被无条件覆盖~~ → 已改成 `if result.success:` / `else:` 两个分支;
失败时是"蒙特卡洛多重积分失败:维度 1,体积 1.0000,模拟 100 次。蒙特卡洛模拟失败:TypeError - ...",
`success` 与 `message` 不再打架。
- ~~② `bounds` 形状没校验~~ → 入口加了 `isinstance(bounds, (list, tuple))` 与 `ndim == 2 and shape[1] == 2` 两道;
`bounds=[0, 1]` 现在报"bounds 必须形如 [(lo, hi), ...],当前形状为 (2,),请检查是否漏了括号"。
- ~~③ `n: int`~~ → 已改 `int | np.integer`。
### ✅ monte_carlo_integral 的 2 条新口径(2026-10-05 已办 1 条、剩 1 条)
- ~~① `bounds` 把 ndarray 拒了~~ → 已加 `isinstance(bounds, np.ndarray)` 分支,`np.array([[0.0, 1.0]])` 现在能收。
- ~~② `if` / `else` 里那三行重复~~ → 已把 `name` / `params["bounds"]` / `params["dimension"]` 提到 `if` 之前,只剩 `message` 分两支。
**还剩 1 条(你的活)**
1. **第一道 `isinstance` 分支现在是冗余的**:`np.asarray` 本来就同时收 list / tuple / ndarray,
而 `[]`、`"abc"`、标量这些都会被下面那句形状校验抓住(报"当前形状为 (0,)"等)。
整段可以塌成两行:`bounds_arr = np.asarray(bounds)` + 形状校验 —— 顺带 `bounds.copy()` 也是多余的一次拷贝。
### ⏳ monte_carlo 只剩 1 个口径要你确认
**`ci_low` / `ci_high` 是不是就按"结果的分位数"定?**
现状 `np.percentile(data, [2.5, 97.5])`。实测 n=2e5、E[X²]=1 给出 `[0.001, 5.0455]` ——
这是**模拟结果的分散度**(预测区间),与 `p05` / `p95` 同类,只是分位点不同。
而"均值估计的置信区间"(MC 的统计误差)是 `mean ± 1.96·std/√n` = `[0.9962, 1.0086]`,
两者差两个数量级,论文话术也完全不同:
- A(现状):写"95% 的模拟结果落在 [a, b]"(描述风险分布)
- B:改成均值 CI → 写"估计值 1.0024 ± 0.0043(95% CI)"(证明 n 够大)
- C:两者都要 → 指标要改名(如 `p025`/`p975` + `mean_ci_low`/`mean_ci_high`)
注意:`monte_carlo_probability` 里的 `ci_*` 已经是**估计量的 CI**(Wilson 区间,决策 50),所以这两个函数的 `ci_*`
目前**含义不同**——这正是要给它们定口径的原因之一。
### ✅ 已拍板(决策 48)
- 批量大小由实现传 `size=n`;`sampler` 必须能接住 `size`,`**kwargs` 里不要再传 `size`。
- 结果含 NaN → `success=False`(不落 metrics)。
- `std` 用 `ddof=0`。
- 出错仍统一返回 `success=False`(`try/except Exception` 保留)。
### ⏳ 另外两个小口径(你说了算)
- **`random_state` 的注解**:经验文档第 3 条写的标准是 `int | np.integer | np.random.Generator | None`,
但 `stats/bootstrap.py`、`solve/simulate.py`、`stats/inference.py` 现在都写 `int | None`(只有 `core/random.py` 是全的)——
要不要全项目对齐?(运行期 `get_rng` 收 `np.int64`,所以是注解与行为不一致,不是功能 bug)
- **`std` 的 `ddof`**:`monte_carlo` 用 `ddof=0`,`describe` 那条线用 `ddof=1`。要不要统一?
### 其余待办
1. L0 的最后一项是 `save_figure`(**A 档 = 工程**,`viz/export.py`)—— **由 Codex 直接实现**,用户只读。
2. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
3. 已拍板的旧口径(留档):`log_transform` 的 `offset` 维持现状(决策 35);
`describe` / `zscore` 默认丢掉非数值列(决策 37);变异系数用 `σ / |μ|`(决策 38);
分工见决策 39;`correlation_matrix` 走方案 A(决策 41)。
4. **仍未答的一个小问题**:`correlation_matrix` 现在只收 `DataFrame`,
而 `describe` / `zscore` / `transform` 都收 ndarray —— 要不要也放开?(你说了算)
5. **`t_test` 有 4 个口径等你拍板(Codex 一律没动,按决策 39)**:
- **① 单样本的结论文案说错了。** `b=None` 时 `test_name` 已经是"单样本 t 检验",
但 `conclusion` 仍写"两组数据存在统计学上的显著差异" —— 单样本根本没有第二组。
建议按分支拼文案(单样本说"该样本均值与 `value` 有显著差异")。
- **② `paired=True` 时 NaN 是按各自的位置丢的**,会把不同行配成一对(静默错位,不报错)。
两个方向:配对前先**成对删缺失**,或**直接拒绝含 NaN 的配对输入**。你定。
- **③ `value` 只在 `b is None` 时生效**,同时传 `b` 和 `value` 会被静默忽略 —— 要不要改成报错?
- **④ `t_test(DataFrame)` 会把所有数值列摊平成一个样本**(已写进 `Notes`)。
多列不同量纲(`gdp` + `pop`)会被混在一起,而且**没法**拿"一张表里两列"当两组比。
要不要改成:多数值列的 DataFrame 直接 `ValueError`、强制先自己取列?
6. **`as_array` 落地后留下的 3 个小口径(Codex 没代选,按决策 39)**:
- `int` / `float` 标量还在白名单里(为兼容 `normalize` / `standardize` / `log_transform`
三个 docstring 写的"数值型标量")。但标量进来是 **0 维数组**,`axis=0` 的算法照样炸 ——
要不要把标量摘掉、同时改三处 docstring?
- 内容转不成 float 时统一抛 `TypeError`(决策 45)—— `normalize(含文本列的表)` 原来抛 `ValueError`。
要不要改回 `ValueError`("取值问题")?
- 现在 `kind == "frame"` 时你要自己再写一遍 `x.select_dtypes(include=[np.number]).columns` 取列名。
嫌烦的话可以让 `as_array` 顺带把选中的列返回(改成三元组),一句话的事。
7. **`bootstrap_ci` 的 5 个待拍板口径**(Codex 一律没动,按决策 39;详见 `docs/algorithms/bootstrap_ci.md` §10):
- `x` 里的 **NaN** 静默传进统计量 → `nan` 区间(`confidence_interval` / `t_test` 都是先丢掉的,口径不齐)
- `n_resamples <= 0` → 现在抛 `IndexError: index -1 is out of bounds`(应在入口给可读 `ValueError`)
- `confidence` 越界 → 现在由 numpy 抛 `Percentiles must be in the range [0, 100]`
- `method="bca"` → docstring 承诺过、实测 `NotImplementedError`(补实现 ≈25 行,还是从 docstring 删掉?)
- `bootstrap_statistic` 仍是 `NotImplementedError`,而 `bootstrap_ci` 把那段重采样循环**内联**了 ——
建议抽出去让 `bootstrap_ci` 调它(易错点 16 的变体:同一段算法写了两遍,只实现了一遍)
File diff suppressed because it is too large. Load diff
+207
View File
@@ -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 个包建齐"相反。
+229
View File
@@ -0,0 +1,229 @@
# 架构设计
> 上游依据:`01-suggestion-review.md`。本文件定义"代码往哪写、边界在哪、接口长什么样"。
---
## 一、设计原则
1. **薄封装优先。** 能用 SciPy / sklearn / statsmodels / NetworkX / SALib 一行解决的,就只做一行转发 + 参数校验 + `Result` 包装。本仓库**不**替代这些库。
2. **自己实现是有目的的。** 只有"库里没有、或者自己写才学得会"的算法才手写(bootstrap、Monte Carlo、DP、SA、熵权、TOPSIS、灰色……)。手写实现要能被读懂、能被改。
3. **统一出口。** 凡是"拟合 / 求解 / 模拟"类函数,一律返回 `Result`,字段固定。
4. **可复现。** 所有含随机的公开函数都接受 `random_state`,默认 `None`(用全局 RNG)。
5. **默认值面向比赛。** 默认参数应当在 4 天赛程里"直接能跑完",不用调。
---
## 二、包结构(7 个包)
```text
src/mcm_toolkit/
├── __init__.py 只导出 6 个高频快捷函数 + 7 个子包
├── _version.py
│
├── core/ ① 基础设施 —— 所有包的地基
│ ├── result.py Result
│ ├── random.py set_seed / get_rng
│ └── validate.py as_array(统一入口)/ check_array / check_same_length / check_probability / check_square
│
├── data/ ② 数据进出 —— 最高频
│ ├── io.py load_table / save_table
│ ├── clean.py clean_missing / impute_missing / detect_outliers / winsorize
│ ├── transform.py normalize / standardize / log_transform / one_hot_encode
│ └── sample.py train_test_split / kfold_split / bootstrap_sample
│
├── stats/ ③ 统计 —— S 级模块
│ ├── descriptive.py describe / zscore / coefficient_of_variation
│ ├── inference.py confidence_interval / t_test / anova / chi_square_test
│ │ fit_distribution / goodness_of_fit
│ └── bootstrap.py bootstrap_statistic / bootstrap_ci ★ 自己实现
│
├── models/ ④ 建模 —— 回归 / 时序 / ML,统一协议
│ ├── base.py Model 协议:fit / predict / score
│ ├── metrics.py rmse / mae / mape / r2_score / adjusted_r2 / compare_models
│ ├── regression.py fit_linear / fit_polynomial / fit_nonlinear / predict
│ ├── timeseries.py exponential_smoothing / fit_arima / forecast
│ └── ml.py fit_forest / fit_logistic / kmeans / pca / cross_validate
│
├── solve/ ⑤ 求解 —— 竞赛主力(把模型反复算)
│ ├── optimize.py minimize / linear_program / integer_program
│ │ dynamic_programming / knapsack_dp ★ 自己实现
│ │ simulated_annealing ★ 自己实现
│ │ pareto_front
│ ├── simulate.py monte_carlo / monte_carlo_probability / monte_carlo_integral
│ │ random_walk / markov_simulate / markov_steady_state
│ │ mm1_metrics
│ ├── sensitivity.py local_sensitivity / one_at_a_time / parameter_sweep
│ │ robustness_test / sobol_analyze / morris_analyze
│ ├── graph.py 【v0.2】build_graph / shortest_path / centrality / pagerank / max_flow
│ └── dynamics.py 【v0.2】solve_ode / solve_system / find_equilibrium / local_stability
│
├── decision/ ⑥ 评价决策 —— 中文赛特色
│ ├── weighting.py entropy_weights / critic_weights / equal_weights
│ ├── ahp.py ahp_weights / ahp_consistency_ratio
│ ├── topsis.py topsis
│ └── grey.py grey_relational_analysis / gm11_fit_predict
│
└── viz/ ⑦ 出图出表 —— 论文产出的最后一公里
├── style.py mcm_style
├── plot.py plot / plot_correlation_matrix / plot_residuals / plot_convergence
└── export.py save_figure / save_table
```
★ = 自己实现(S 档,配完整函数卡片)
---
## 三、与 ChatGPT 原方案的对照
| 原方案 | 现方案 | 变更理由 |
|---|---|---|
| 14 个包 | 7 个包 | 见 `01-suggestion-review.md` §3.1 |
| `regression/` + `ml/` | 合并 `models/` | 原方案里 `fit_linear` 与 `fit_linear_regression` 重复 |
| `optimization/` + `simulation/` + `sensitivity/` | 合并 `solve/` | 三者共享 Result / rng / 并行约定,分开会重复三遍 |
| `dynamics/` `network/` | 降级为 `solve/dynamics.py` `solve/graph.py`,进 v0.2 | 低频,不值得独立包 |
| `utils/metrics.py` + 单列 `analysis/` | 合并 `models/metrics.py` | 原方案三处重复放同一批指标 |
| 13 个 `plot_*` | `plot(kind=...)` + 3 个专用图 | 改全局样式不必改 13 处 |
| 5 个 Result 类 | 1 个 `Result` | 第一版没有一个函数需要区分五种结果 |
---
## 四、关键约定
### 4.1 `Result`(唯一结果对象)
```python
@dataclass
class Result:
name: str # 产生它的函数名
params: dict | None = None # 参数估计 / 决策变量取值
metrics: dict | None = None # 评估指标
data: Any = None # 主要载荷(预测值 / 轨迹 / 排序……)
success: bool = True
message: str = ""
```
约定:
- 任何"拟合 / 求解 / 模拟 / 评价"函数**只返回 `Result`**,不返回裸 tuple。
- `Result` 支持 `repr` 直接打印成"指标表",方便贴进论文草稿。
- 后续真需要区分类型时再派生,不提前建 5 个类。
### 4.2 随机数
```python
set_seed(seed) -> None # 全局种子
get_rng(random_state=None) -> np.random.Generator
```
- 所有含随机的函数签名统一含 `random_state=None`,内部第一行 `rng = get_rng(random_state)`。
- **禁止**直接用 `np.random.*` 旧接口,禁止裸用 `random` 模块。
- 论文附录必须写明 seed。
### 4.3 模型协议
```python
class Model(Protocol):
def fit(self, X, y, **kw) -> "Model": ...
def predict(self, X) -> np.ndarray: ...
def score(self, X, y) -> dict: ... # 返回指标 dict,不是单个 float
```
- 这样 `compare_models({"linear": m1, "rf": m2}, X, y)` 才能统一执行。
- `compare_models` 返回 `pd.DataFrame`(行=模型,列=指标),可直接 `save_table(..., fmt="latex")`。
### 4.4 指标方向与量纲(评价类必读)
- `normalize` 只做**无量纲化**(min-max / 向量归一),不改变指标方向。
- 方向(效益型 / 成本型)在 `decision` 层处理,用参数 `benefit_columns` 显式声明。
- **不允许**默认假设"越大越好"。
### 4.5 import 规则
- 顶层 `mcm_toolkit/__init__.py` 只导出子包 + 6 个快捷函数,**不 `import *`**。
- 子包之间允许单向依赖:`core ← data / stats / models / solve / decision / viz`;`viz` 可依赖其余全部;**禁止反向依赖**。
- `__all__` 是公开 API 清单,不写"内部工具"。
### 4.6 依赖分层
| 层 | 包 | 说明 |
|---|---|---|
| 核心(必装) | `numpy` `scipy` `pandas` `matplotlib` | `core` `data` `stats` `viz` 只需要这些 |
| `[ml]` | `scikit-learn` `statsmodels` | `models/` |
| `[graph]` | `networkx` | `solve/graph.py` |
| `[sensitivity]` | `SALib` | `sobol_analyze` / `morris_analyze` |
| `[symbolic]` | `sympy` | `solve/dynamics.py` |
| `[moo]` | `pymoo` | 真需要 NSGA-II 时 |
| `[excel]` | `openpyxl` | Excel 读写 |
| `[dev]` | `pytest` `ruff` `pyright` | 开发 |
**可选依赖缺失时必须给出可读报错**,例如 `sobol_analyze` 在没装 SALib 时提示 `pip install "mcm-toolkit[sensitivity]"`,而不是 `ModuleNotFoundError` 裸抛。
### 4.7 类型约定(每个函数都适用)
公开函数的**参数类型注解必须写全**,并且与 docstring 说的是同一件事:注解给 IDE / pyright 看,
docstring 给人看。两处打架时,合法用法会被标红,真正的错误反而看不见。
判断顺序照抄下表,**不要凭直觉**:
| 值可能来自 | 怎么写 | 为什么 |
|---|---|---|
| 整数 | `isinstance(x, (int, np.integer))`,取值统一 `int(x)` | `np.int64` **不是** `int` 的子类,只写 `int` 会漏 |
| 浮点 | `isinstance(x, (float, np.floating))` | `np.float64` 是 `float` 子类,但 `np.float32` 不是——不统一 |
| 布尔 | `isinstance(x, bool)` 要放在 `int` 判断**之前** | `True` 也是 `int`,顺序反了会当成 1 |
| 数组 / 表 | `np.asarray(x, dtype=float)` 后再校验 | 别假设调用方已经转好类型 |
**非法类型必须 `raise TypeError`,不许静默兜底。** 静默的典型后果是
"种子被忽略、结果不可复现,但没有任何提示"——比直接报错难查一百倍。
配套要求,每个公开函数都要有:
- `Parameters` 写清类型与含义;允许 `None` 时,写明 `None` 会走哪条分支
- `Raises` 写清会抛什么异常
参考实现:`core/random.py` 的 `get_rng`。
### 4.8 入口只有一个:`core.validate.as_array`
**凡是要数值数组的函数,第一行都从 `as_array` 进**,不要各自抄
`isinstance(x, pd.DataFrame)` / `select_dtypes` / `np.asarray`:
```python
arr, kind = as_array(x, numeric_only=False) # 入口:白名单 + 转 ndarray + 报告原类型
... # 中间只写算法
if kind == "frame": # 出口:自己手写,各函数形状不同,没法一刀切
return pd.DataFrame(result, index=x.index, columns=x.columns)
if kind == "series":
return pd.Series(result, index=x.index, name=x.name)
return result
```
三条硬规矩:
1. **`numeric_only` 按"输出契约"选,不按喜好选**:输出要与输入**同形**的函数
(`normalize` / `standardize` / `log_transform`)必须 `False` —— 跳过非数值列会让返回的表少列、
对不上原表;输出是"一张数值结果表"的(`zscore` / `confidence_interval` / `t_test`)用默认的 `True`。
2. **默认不摊平**。要摊平必须显式 `ndim=1`(`t_test` 曾因静默摊平把 `gdp` 和 `pop` 混成一个样本)。
3. **`as_array` 不删缺失值**。`df.corr` 成对删、`describe` 整列统计、`paired=True` 的 t 检验必须**成对**删 ——
在这一层删掉,行与行的对应关系就永久丢了。删缺失是**语义**,归各函数。
只走入口、不走出口的(输出与输入不同形):`describe`、`correlation_*`、`confidence_interval`、`t_test`。
---
## 五、测试策略
- 目录 `tests/` 与包一一对应(`tests/test_stats.py` ↔ `stats/`)。
- **测已知解析解,不测实现细节**:
| 函数 | 测法 |
|---|---|
| `monte_carlo_integral` | 对 `∫₀¹ x² dx = 1/3` 收敛,误差 < 1e-2(n=1e5) |
| `bootstrap_ci` | 正态样本大 n 下,百分位 CI ≈ 解析 CI(±5%) |
| `confidence_interval` | 与 `scipy.stats.norm.interval` 对齐 |
| `fit_polynomial` | 与 `np.polyfit` 系数对齐(`allclose`) |
| `detect_outliers` | 人工构造带 3 个离群点的数组,命中数 = 3 |
| `topsis` | 与手算的排序一致 |
- 随机相关测试一律固定 `random_state`。
- 每条断言都要能说清"错了说明什么"。
+118
View File
@@ -0,0 +1,118 @@
# 路线图与开发纪律
> 依据:`01-suggestion-review.md` §6–§8。
> 进度看根目录 `STATUS.md`,本文件只定义"做什么、做到什么程度、什么不做"。
---
## 一、功能清单分级
### L0 —— 第一周必做(12 个)
没有这 12 个,工具箱根本跑不起来;有了这 12 个,已经能应付大部分题目。
| 函数 | 所在模块 | 档位 |
|---|---|---|
| `Result` | `core/result.py` | A |
| `get_rng` / `set_seed` | `core/random.py` | A |
| `load_table` | `data/io.py` | A |
| `clean_missing` | `data/clean.py` | A |
| `detect_outliers` | `data/clean.py` | **S** |
| `normalize` | `data/transform.py` | A |
| `standardize` | `data/transform.py` | A |
| `describe` | `stats/descriptive.py` | A |
| `correlation_matrix` | `stats/inference.py` | A |
| `bootstrap_ci` | `stats/bootstrap.py` | **S** |
| `monte_carlo` | `solve/simulate.py` | **S** |
| `save_figure` | `viz/export.py` | A |
> `correlation_matrix` 有两个**伴随函数**(决策 41):`correlation_pvalues` 给 p 值矩阵、
> `correlation_table` 给"系数 + 星标"的论文表格。三者共用同一套入参守卫与**成对删缺失**口径。
### L1 —— v0.1(共约 40 个)
在 L0 基础上补齐:
| 模块 | 函数 |
|---|---|
| `core` | `check_array` `check_same_length` `check_probability` `check_square_matrix` |
| `data` | `impute_missing` `winsorize` `log_transform` `one_hot_encode` `train_test_split` `kfold_split` `bootstrap_sample` |
> `core.validate.as_array`(**统一入口**,决策 44)已提前落地。它不是"多一个公开 API",
| `stats` | `zscore` `coefficient_of_variation` `confidence_interval` `t_test` `anova` `chi_square_test` `fit_distribution` `goodness_of_fit` `bootstrap_statistic` |
| `models` | `rmse` `mae` `mape` `smape` `r2_score` `adjusted_r2` `compare_models` `fit_linear` `fit_polynomial` `fit_nonlinear` `predict` `exponential_smoothing` `fit_arima` `forecast` `fit_forest` `fit_logistic` `kmeans` `pca` `cross_validate` |
| `solve` | `minimize` `linear_program` `integer_program` `dynamic_programming` `knapsack_dp` `simulated_annealing` `pareto_front` `monte_carlo_probability` `monte_carlo_integral` `random_walk` `markov_simulate` `markov_steady_state` `mm1_metrics` `local_sensitivity` `one_at_a_time` `parameter_sweep` `robustness_test` `sobol_analyze` `morris_analyze` |
| `decision` | `equal_weights` `entropy_weights` `critic_weights` `ahp_weights` `ahp_consistency_ratio` `topsis` `grey_relational_analysis` `gm11_fit_predict` |
| `viz` | `mcm_style` `plot` `plot_correlation_matrix` `plot_residuals` `plot_convergence` `save_table` |
### L2 —— v0.2
- `solve/graph.py`:`build_graph` `shortest_path` `min_spanning_tree` `betweenness_centrality` `pagerank` `max_flow` `community_detection`
- `solve/dynamics.py`:`solve_ode` `solve_system` `find_equilibrium` `local_stability`
**触发条件**:在真题里真的用到了,或者要为 MCM A / ICM D 备赛时再写。
---
## 二、永久不做(防膨胀清单)
> 原文用"建议不要",这里写成**硬规则**。往这个清单里加东西,必须先改这份文档。
- 不重写 SciPy 的优化器、`scipy.stats` 的分布、`scipy.integrate` 的积分器
- 不重写 sklearn 的 estimator(`fit/predict` 之外一律不做)
- 不重写 statsmodels 的时间序列框架
- 不重写 NetworkX 的图算法
- 不重写 SALib 的 Sobol / Morris / FAST
- 不实现 NSGA-II / SPEA2 等完整多目标进化算法(用 pymoo)
- 不实现符号计算引擎(用 SymPy)
- 不实现完整离散事件仿真引擎(`simulate_queue` 那种)
- 不做深度学习 / PyTorch 相关任何东西
- 不做 GUI、不做 Web 界面、不做 CLI 之外的产品化
- 不做自动微分、不做 GPU 后端
- 不为"完整性"补函数——**没在真题里用过三次的函数不写**
---
## 三、开发纪律
### 3.1 文档两档制
**S 档**(招牌算法,自实现):完整函数卡片,模板见 `dev/function-card-template.md`。
覆盖:`detect_outliers` `bootstrap_ci` `monte_carlo` `monte_carlo_probability`
`monte_carlo_integral` `dynamic_programming` `knapsack_dp` `simulated_annealing`
`local_sensitivity` `sobol_analyze` `entropy_weights` `topsis` `ahp_weights`
`gm11_fit_predict`。约 15 个。
**A 档**(薄封装):docstring 里一句话数学含义 + 一句话什么时候用 + 一条 pytest。
判断标准:**我会不会去改它的内部实现?** 会 → S 档。
### 3.2 每写一个函数
1. 先想"这道题里它替代了哪段手写代码"——想不出来就先不写。
2. 先在 `tests/` 里写一条会失败的断言(测已知解析解,不测实现细节)。
- 参数类型注解按 `02-architecture.md` §4.7 写全;非法类型抛 `TypeError`,不静默兜底。
3. 实现到测试通过。
4. 在 `examples/` 里加一行最小用法。
5. 更新 `STATUS.md`。
### 3.3 提交前
```powershell
.venv\Scripts\python.exe -m pytest -q
.venv\Scripts\python.exe -m ruff check src tests
```
---
## 四、时间盒
| 周 | 目标 | 完成标志 |
|---|---|---|
| 1 | `core` + `data` + `stats`;S 档先写 `bootstrap_ci` `monte_carlo` `detect_outliers` | L0 全部通过测试 |
| 2–3 | `models` + `solve/optimize` + `solve/simulate` | `compare_models` 出对比表 |
| 4 | `solve/sensitivity` + `decision` + `viz` | 一条端到端 example |
| 5–6 | 用一道真题走完整流程,反向补函数 | `problems/<年份>_<题号>/` 完成 |
| 之后 | 每做一题沉淀 1~3 个函数 + 对应 test | 仓库自然长大 |
**核心原则:先跑通一条完整链路,再补宽度。**
+443
View File
@@ -0,0 +1,443 @@
# 环境配置
> 记录本机(Windows / PowerShell)上 conda 与 venv 两套环境的**实际**配置方式、
> 实测数据、以及踩过的坑。相关脚本在 `scripts/`。
>
> 状态:**两套环境均已建好、版本已对齐、验证通过**(2026-10-01)。
---
## 一、结论速查
| | venv | conda env |
|---|---|---|
| 路径 | `D:\MCM&ICM_Toolkit\.venv` | `D:\anaconda\envs\mcm-toolkit` |
| 解释器 | Anaconda base,Python **3.13.9** | Python **3.13.15** |
| 占用 | 10.5 MB | 1.55 GB(+ 共用 pkgs 缓存) |
| 来源 | `--system-site-packages` 复用 base | conda 从 defaults 通道安装 |
| 用途 | 轻量开发 / 离线兜底 | **跑题主力环境** |
| 缺什么 | SALib、pymoo、pyright | 无缺口 |
**两套的数值计算栈已完全一致**(见 §5),只差 Python 补丁号。可以放心在两套之间切换。
---
## 二、venv(轻量、离线可用)
```powershell
D:\anaconda\python.exe -m venv --system-site-packages --prompt "mcm-toolkit" .venv
.venv\Scripts\python.exe -m pip install -e . --no-deps --no-build-isolation
```
一键:`./scripts/setup-venv.ps1`
### 为什么用 `--system-site-packages`
让 venv 直接复用 Anaconda base 里已有的包,**零下载**就有可用的科学栈,
而且只有 10.5 MB,可以安心放在项目目录里。
已验证可导入(版本随 base 走,2026-10-01 base 升级后为):
`text
numpy 2.4.6 scipy 1.18.1 pandas 3.0.6 matplotlib 3.11.2
scikit-learn 1.9.1 statsmodels 0.15.0 networkx 3.6.1 sympy 1.14.0
`
### `--no-deps --no-build-isolation` 是必须的
* `--no-deps`:依赖已由 base 提供,不必联网解析;
* `--no-build-isolation`:否则 pip 会新建隔离环境去下载 setuptools 构建后端。
---
## 三、conda 环境 `mcm-toolkit`(跑题主力)
```powershell
# 一键(推荐)
.\scripts\setup-conda.ps1
# 等价的手工命令
$env:CONDA_PLUGINS_AUTO_ACCEPT_TOS = 'yes' # 见 §4 坑 1
conda create -n mcm-toolkit -y python=3.13 ^
numpy scipy pandas matplotlib scikit-learn statsmodels ^
networkx sympy openpyxl pytest ruff pip
```
**实测:约 14.5 分钟(868 秒),下载约 3 GB,环境本体 1.55 GB。**
建完后补装 pip-only 的依赖并安装本项目:
```powershell
$py = 'D:\anaconda\envs\mcm-toolkit\python.exe'
& $py -m pip install SALib pymoo pyright
& $py -m pip install -e . --no-deps --no-build-isolation
```
这些 `setup-conda.ps1` 已全部自动完成。
### 为什么不推荐 `--clone base`
原以为 `conda create -n X --clone base` 是"本地复制、秒级完成、不用下载"。
**实测不是。** 在 conda 25.11 上它会重放 base 的完整包列表并逐个下载安装
(连 pandoc、m2-msys2-runtime、anaconda-navigator 这些都用不上的也照下),
跑 253 秒才下完约 2 GB 就已被中止,按比例算远慢于精准列表。
所以 `setup-conda.ps1` 的默认路径是**精准包列表**;`-CloneBase` 只在你确实想
要一份 base 的完整副本时才用。
### 断网怎么办
**提前建好环境**,并保留 `D:\anaconda\pkgs` 缓存(现在 8.4 GB)。
包已在缓存里时 `conda create --offline` 才能工作;否则会失败。
比赛现场不要指望临时建环境,走 venv(它 100% 离线可用)。
---
## 四、踩过的坑
**坑 1 —— Anaconda ToS 插件要联网。**
Anaconda 25.x 自带 `conda_anaconda_tos` 插件,任何 `create/install` 之前都会去
`repo.anaconda.com` 拉 ToS 元数据。断网时它不是报"没网",而是抛一长串 traceback。
```powershell
$env:CONDA_PLUGINS_AUTO_ACCEPT_TOS = 'yes' # 推荐:跳过联网,保留 libmamba 求解器
conda --no-plugins create ... # 彻底关插件(退化成经典求解器,慢)
```
**坑 2 —— `envs_dirs` / `pkgs_dirs` 必须可写。**
不可写时报 `NotWritableError` 或
`Error encountered while attempting to create cache directory`。排查:
```powershell
conda info --json | Select-String "envs_dirs","pkgs_dirs"
conda config --show envs_dirs pkgs_dirs
```
**坑 3 —— 被中止的 `conda create` 用 `conda env remove` 删不干净。**
本次中止后 `conda env remove -n mcm-toolkit -y` 跑完,`D:\anaconda\envs\mcm-toolkit`
**仍然存在**且 `conda env list` 里已看不到它。要手工删目录:
```powershell
Remove-Item -LiteralPath 'D:\anaconda\envs\mcm-toolkit' -Recurse -Force
```
**坑 4 —— 别把 conda env 建在项目目录里。**
`conda create -p ./.conda` 会把 GB 级目录放进仓库,脏 `.gitignore` 也容易误提交。
**坑 5 —— 版本漂移。**
本机 base 是 2025-10 装的,defaults 通道已是 2026-10 的版本,于是新建的 conda
环境拿到的是更新的版本(pandas 从 2.3.3 跳到 **3.0.6**,是 major 升级)。
见 §5,写代码时注意别依赖两套环境都有的行为。
**坑 6 —— conda 升级留下的 `*.conda_trash` 残骸会被 Python 当成包导入。**
base 从 2025.12 升到 2026.07 后,`D:\anaconda\Lib\site-packages\zstandard\` 里只剩一个
`backend_c.cp313-win_amd64.pyd.c~.conda_trash`。目录没了 `__init__.py`,Python 就把它当作
**namespace package** 导入成功,于是:
```text
>>> import zstandard
>>> zstandard.__file__ # None —— 导入"成功"了,但什么都没拿到
>>> zstandard.__version__
AttributeError
```
pandas 3.0 的 `import_optional_dependency("zstandard")` 拿到这种半残包时,抛的不是
`ModuleNotFoundError`(那会被正常忽略),而是
`ImportError: Can't determine version for zstandard`,直接**打断 `pd.read_csv`**。
症状很迷惑:同一个脚本今天能跑明天不能跑,报错还指向 csv 读取。
**怎么查**(关键是 `__file__` 和 `__version__` 两项一起看):
```powershell
& 'D:\anaconda\python.exe' -c "import zstandard; print(zstandard.__file__, getattr(zstandard,'__version__','MISSING'))"
Get-ChildItem -LiteralPath 'D:\anaconda\Lib\site-packages\zstandard' -Force
```
**怎么修**:确认 conda 记录里本来就没有这个包(`conda list -n base | findstr zstandard`
只该看到 `backports.zstd` / `zstd`),再把这个残骸目录改名禁用(比删掉可逆):
```powershell
Move-Item -LiteralPath 'D:\anaconda\Lib\site-packages\zstandard' `
-Destination 'D:\anaconda\Lib\site-packages\zstandard.disabled-conda-trash'
```
改名后 `import zstandard` 变成正常的 `ModuleNotFoundError`,pandas 会安静地跳过它。
本次已按此处理,`.venv` 的 `pd.read_csv` 恢复正常。
**坑 7 —— 编辑器报"无法解析导入 numpy / pandas",但解释器明明是对的。**
现象:`.venv` 与 conda 环境里 `import numpy, pandas` 都正常,pytest 也全过,
只有编辑器/pyright 满屏 `无法解析导入"numpy"`。
原因是**静态分析器自己挑了一个 python**,跟你运行代码用的那个没关系。本机上
`pyright --verbose` 一跑就露馅:
```text
Assuming Python version 3.14.6.final.0
Search paths:
c:\Users\guzhu\AppData\Roaming\Python\Python314\site-packages
c:\Users\guzhu\AppData\Local\Python\pythoncore-3.14-64\Lib\site-packages
... <- 一个 Anaconda 路径都没有
```
那个 3.14 来自 PATH 上的 `python`,而 PATH 上的 `python` 是本机唯一的 python:
```text
where python -> C:\Users\guzhu\agent-tools\bin\python.cmd
\-> C:\Users\guzhu\.cache\codex-runtimes\...\python\python.exe
```
**Codex 运行时的 shim**(Python 3.14,没有 numpy/pandas)。分析器从 PATH 找到它,
自然解析不到科学栈。注意持久化的 User/Machine PATH 里**并没有**这一项,
它是被 Codex 进程临时注入的——所以"我的终端里没问题"和"工具有问题"可以同时成立。
还有第二层原因:`.venv` 是 `--system-site-packages` 建的,三方包都在
`D:\anaconda\Lib\site-packages`,而 **pyright / Pylance 不认 `pyvenv.cfg` 里的
`include-system-site-packages`**,只会去 `.venv\Lib\site-packages` 找——
那里只有 `pip`,于是照样报解析不到。
修法(两件事都要做):
1. 在 `pyproject.toml` 里把分析器钉到工作区 venv,别让它去 PATH 上碰运气:
```toml
[tool.pyright]
venvPath = "."
venv = ".venv"
pythonVersion = "3.13" # 不写就跟着 PATH 上那个 3.14 走
```
2. 在 `.venv\Lib\site-packages` 放一个 `.pth` 指向 base 的 site-packages。
pyright **不读** `pyvenv.cfg`,但**会读 `.pth`**:
```powershell
& 'D:\anaconda\python.exe' -c "import sysconfig; print(sysconfig.get_paths()['purelib'])"
# 把输出的那一行原样写进 .venv\Lib\site-packages\_mcm_base_site_packages.pth
```
`scripts/setup-venv.ps1` 已经自动做这一步(第 [2/4] 步),换机重建 `.venv` 不用手动补。
改完 pyright 从 `17 errors` 掉到 `0 errors`。IDE 侧还要各做一件事:
* **VS Code / Cursor**:`Ctrl+Shift+P` → Python: Select Interpreter → 选
`D:\anaconda\envs\mcm-toolkit\python.exe`(或 `.venv\Scripts\python.exe`),
然后 Developer: Reload Window。Pylance 有缓存,不重载不生效。
* **PyCharm**:Settings → Project → Python Interpreter 选同一个,
再 File → Invalidate Caches / Restart。
**一句诊断口诀**:编辑器说"解析不到包"时,先跑 `pyright --verbose` 看它的
`Search paths`,而不是先去 `pip install`。
**坑 8 —— `df[Index]` 被 pyright 当成 `Series`,`.std()` 的结果于是被判成标量。**
现象:`data/clean.py` 里
```python
numeric = data[numeric_cols] # numeric_cols = df.select_dtypes(...).columns
std = numeric.std()
std = std.replace(0, np.nan) # pyright: 无法访问类"float"的属性"replace"
```
运行时 `numeric` 明明是 DataFrame、`std` 明明是按列的 Series,pyright 却报这个错。
原因:pandas 自带的类型注解里,`DataFrame.__getitem__` 只声明了「`Hashable` → `Series`」
这一支。拿一个 `Index` 去索引时它挑中这个重载,`numeric` 就被推断成 `Series | DataFrame`;
而 `Series.std()` 返回的是**标量**,两支一取并集就把 `float` 带了进来 —— `float` 当然没有
`.replace`。用 `reveal_type` 一问就现原形:
```text
“numeric”的类型为“Series | Unknown | Self@DataFrame”
“numeric.std(axis=0)”的类型为“Series | float | Unknown”
```
几个**没用**的修法(都实测过):
- `numeric.std(axis=0)` —— 接收方本身还是并集,重载照样解析成并集;
- `data[list(numeric_cols)]` —— 不改变重载选择;
- 把参数改由一个类型明确的 `frame` 局部变量承接 —— `frame` 确实干净了,
但 `frame[Index]` 还是同一个重载问题。
有效的修法是**只在取列子表那一句上按运行时的真值收窄**:
```python
from typing import cast
numeric = cast(pd.DataFrame, data[numeric_cols])
```
`cast` 零运行时开销,而且能把**下面所有行**留在类型检查范围内 —— 比在 `.replace` 那一行
挂 `# pyright: ignore` 好:那样等于连带放弃后面每一行的检查。
**一句诊断口诀**:pyright 说某行"属性不存在"、但运行时确实能跑,先 `reveal_type(...)`
把真实推断打出来,看并集里混进了什么,别急着加 `# type: ignore`。
**坑 9 —— pandas 3 起字符串列的 dtype 是 `str`,`select_dtypes(include=["object"])` 已走弃用路径。**
现象:`one_hot_encode` 自动识别类别列时只写了 `['object', 'category']`,
功能看起来正常,但**每次调用都发一条 `Pandas4Warning`**,而且 warning 里写着这个行为
"will be removed in a future version"。
```text
For backward compatibility, 'str' dtypes are included by select_dtypes when 'object'
dtype is specified. This behavior is deprecated and will be removed in a future version.
```
原因:pandas 3 起字符串列的 dtype 是 `str`(不再是 `object`)。当前版本为了向后兼容
仍然把 `str` 列算进 `object`,但那是**过渡行为**。等它真的移除,`include=['object']`
会一列都选不中 —— 那时 `one_hot_encode(df)` 会**静默**返回未编码的原表,
不报错、不告警,是最难查的那类错。
实测(本机 pandas 3.0.6):
| 写法 | 选中 | 告警 |
|---|---|---|
| `include=['object', 'category']` | ✅ | **1 条** |
| `include=['object', 'category', 'str', 'string']` | ✅ | 0 |
| `include=['str', 'string']` | ✅ | 0 |
修法:把 `'str'` / `'string'` 显式写进 `include`(这也是 pandas 官方迁移指南推荐的写法,
同时兼容 pandas 2 与 3)。配套测试用 `warnings.catch_warnings(record=True)` 断言
**一条告警都不该有**,这样以后 pandas 再收紧行为时会立刻变红,不会静默。
**一句诊断口诀**:`select_dtypes` 选不中东西时,先 `print(df[col].dtype)` 看真实 dtype,
别照着记忆里的 `object` 写。
---
**坑 10 —— scipy 的检验结果在类型检查器里没有 `pvalue` / `statistic` 字段。**
现象:`_, p = stats.pearsonr(x, y)` 运行没问题,但 pyright 报
`无法将 "_T_co@tuple" 类型的参数分配给 ... 的 "float"`;改成 `.pvalue` 又报
`无法访问类 "PearsonRResult" 的属性 "pvalue"`。两条都像"库的锅",其实是存根看不全。
原因:`PearsonRResult` / `SignificanceResult` 不是普通的 `namedtuple`,
而是在 `scipy/stats/_stats_py.py` 里用 `_make_tuple_bunch(...)` 在**运行时**拼出来的类
(`PearsonRResult` 还继承自 `PearsonRResultBase`)。类型检查器看不到运行时才生成的字段,
于是解包会退化成 `object`、属性访问会报"未知"。
**这不是运行时问题** —— `pearsonr` / `kendalltau` / `spearmanr` 返回的 p 实测都是 `np.float64`。
修法:不要逐处挂 `# type: ignore`,写一个小助手把结果显式收成 `Any`:
```python
def _pvalue(result: Any) -> float:
return float(result.pvalue)
def _statistic(result: Any) -> float:
return float(result.statistic)
```
一处收窄、调用点全干净,`t_test` / `anova` / `chi_square_test` / `fit_distribution` 都能复用。
**`TtestResult` 同理。** `stats.ttest_1samp` / `ttest_ind` / `ttest_rel` 返回的也是
`_make_tuple_bunch` 在运行时拼出来的类,存根里 `.statistic` 和 `.pvalue` **两个字段都没有**。
`inference.py` 的 `t_test` 里 `float(res.statistic)` / `float(res.pvalue)` 两行就是这么被 pyright 标红的
(实测:`TtestResult(statistic=3.873, pvalue=0.0305)`,运行时取值完全正常)。
换成 `_statistic(res)` / `_pvalue(res)` 后红线消失,**一行 `# type: ignore` 都没挂**。
---
## 五、两套环境的实际版本(已对齐)
**2026-10-01 已把 base 从 Anaconda 2025.12 升到 2026.07,两套环境版本已对齐。**
| 包 | venv(继承 base) | conda `mcm-toolkit` | |
|---|---|---|---|
| Python | 3.13.9 | 3.13.15 | 补丁号,可忽略 |
| numpy | 2.4.6 | 2.4.6 | ✅ 一致 |
| scipy | 1.18.1 | 1.18.1 | ✅ 一致(连 build 号都相同) |
| pandas | 3.0.6 | 3.0.6 | ✅ 一致 |
| matplotlib | 3.11.2 | 3.11.2 | ✅ 一致 |
| scikit-learn | 1.9.1 | 1.9.1 | ✅ 一致 |
| statsmodels | 0.15.0 | 0.15.0 | ✅ 一致 |
| networkx | 3.6.1 | 3.6.1 | ✅ 一致 |
| sympy | 1.14.0 | 1.14.0 | ✅ 一致 |
| pytest | 9.1.1 | 9.1.1 | ✅ 一致 |
| SALib / pymoo / pyright | 缺失 | 已装 | venv 本来就只做轻量开发 |
| BLAS 后端 | **MKL** | **MKL** | ✅ 一致 |
### base 升级记录
```powershell
conda update -n base --all -y # 365 个包更新,下载 638 MB,耗时 1033 秒
conda install -n mcm-toolkit numpy=2.4.6 -y # 把项目环境对齐到 base 的 numpy
```
* `conda` 本身从 25.11.1 升到 **26.7.3**。
* 升级前 `conda list -n base --revisions` 里已留有 revision 记录,可回滚:
`conda install -n base --revision <N>`。
* **踩过的坑**:base 想装 `numpy=2.5.3` 时求解器会顺手把 BLAS 从 **MKL 换成 OpenBLAS**
(`_anaconda_depends 2026.07 ..._mkl_1 → ..._openblas_1`、`blas 1.0-mkl → 1.0-openblas`)。
`_anaconda_depends` 把 base 的 numpy 钉在 2.4.6,硬升会牵动整套线性代数后端,
所以选择把项目环境的 numpy **降到 2.4.6** 来对齐,而不是动 base。
* 升级期间遇到 `repo.anaconda.com` 读超时(默认 9.15 秒太短),已把
`remote_read_timeout_secs` 调到 120、`remote_connect_timeout_secs` 调到 30、
`remote_max_retries` 调到 5。这几项写在 `~/.condarc` 里。
## 六、国内镜像(需要装新包时)
```powershell
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main
conda config --set show_channel_urls yes
# pip(对 venv 和 conda 环境都生效)
python -m pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
```
> 本机当前通过 `http://127.0.0.1:7993` 的 UniClash 代理直连,未配镜像。镜像只影响**下载**,
> 不影响坑 1 的 ToS 行为。
---
## 七、可选依赖
核心 4 个包(`numpy scipy pandas matplotlib`)两套环境都有。其余按需:
```powershell
python -m pip install -e ".[ml]" # sklearn statsmodels 已有
python -m pip install -e ".[graph]" # networkx 已有
python -m pip install -e ".[sensitivity]" # SALib conda 环境已有
python -m pip install -e ".[symbolic]" # sympy 已有
python -m pip install -e ".[moo]" # pymoo conda 环境已有
python -m pip install -e ".[excel]" # openpyxl 已有
python -m pip install -e ".[dev]" # pytest ruff pyright
```
缺依赖时对应函数应当给出可读的安装提示,而不是裸抛 `ImportError`
(写成约定,见 `docs/02-architecture.md` §4.6)。
---
## 八、脚本的编码约定(踩过的坑)
`scripts/` 下的 `.ps1` **必须是纯 ASCII + UTF-8 无 BOM + LF**。
原因是 Windows PowerShell 5.1 **在没有 BOM 时按 ANSI(本机 GBK)解析 `.ps1`**,
中文注释和中文消息会被拆成非法字节,报出来的却是一堆莫名其妙的
`Missing closing '}' in statement block` / `The string is missing the terminator`。
(实测:带中文的 `doctor.ps1` 直接语法错误;改成纯 ASCII 后立刻正常。)
两种可行方案,本项目选了第二种:
1. 保留中文 → 必须存成 **UTF-8 with BOM**(违反 AGENTS.md 的"无 BOM"约定);
2. **纯 ASCII,注释与输出用英文** → 符合 AGENTS.md,且任何编码下都能解析。← 采用
另一个相关的坑:**PowerShell 5.1 向原生程序传参时会吃掉双引号**,
所以 `doctor.ps1` 里传给 `python -c` 的探测代码只能用单引号字符串。
---
## 九、已知的沙箱相关现象
如果在受限环境(文件系统只读 / 无网络)里跑 matplotlib,会看到:
```text
Matplotlib created a temporary cache directory at ... because there was an issue with
the default path (C:\Users\guzhu\.matplotlib)
```
这不是配置问题,是 `~\.matplotlib` 不可写。正常终端里不会出现;
真遇到时设置 `MPLCONFIGDIR` 指向可写目录即可:
```powershell
$env:MPLCONFIGDIR = "$env:TEMP\matplotlib"
```
+190
View File
@@ -0,0 +1,190 @@
# Git 工作流与远程托管
> 本仓库**已初始化**:分支 `main`,首次提交 `5319589`(76 文件),工作区干净。
> 尚未配置远程,之后托管到你自己的 Gitea。
---
## 一、仓库约定
### 行尾
由 `.gitattributes` 强制:**仓库内一律 LF**,checkout 时不做自动转换。
原因:AGENTS.md 要求 shell 脚本 UTF-8 无 BOM + LF;而这个仓库会被队友在
Windows / WSL / Linux 上 clone。不固定行尾的话,每次提交都会出现
"整个文件都变了"的假 diff,review 时根本看不出真实改动。
例外:`*.bat` / `*.cmd` 保持 CRLF(cmd 解析需要)。
Windows 用户建议同时设:
```powershell
git config --global core.autocrlf false
git config --global core.eol lf
```
### 什么不入库
见 `.gitignore`。重点是三条:
| 忽略 | 为什么 |
|---|---|
| `.venv/`、`.conda/` | 环境可由 `scripts/setup-*.ps1` 重建,不该进版本库 |
| `problems/**/raw/`、`**/*.xlsx`、`problems/**/*.csv` | 赛题原始数据体积大、可重新下载;只提交**处理脚本和结论** |
| `figures/*.png`、`figures/*.pdf`、`tables/*.csv` | 中间产物。定稿的图请放进 `problems/<年份>_<题号>/figures/` 再提交 |
**判断标准:这个东西能不能由仓库里的其他文件重新生成?**
能 → 不入库。
---
## 二、日常提交
```powershell
git add -A
git commit -m "feat(stats): 实现 bootstrap_ci"
```
提交信息建议带模块前缀,方便以后回溯"这个函数是哪次加的":
| 前缀 | 用途 |
|---|---|
| `feat(<模块>)` | 新增函数 / 功能 |
| `fix(<模块>)` | 修 bug |
| `test(<模块>)` | 只改测试 |
| `docs` | 只改文档 |
| `env` | 环境、依赖、脚本 |
| `chore` | 其他杂项 |
提交前跑一遍:
```powershell
.\scripts\doctor.ps1
```
它会同时检查 venv 和 conda 两套环境并跑测试。
---
## 三、远程托管(已配置)
| 项 | 值 |
|---|---|
| Gitea 网页 | https://code.guzhujushi.cn/guzhujushi/MCM-ICM_Toolkit |
| 服务器公网 IP | `8.145.36.108`(实测只开了 443,**22 被防火墙过滤**) |
| 服务器 Tailscale IP | `100.115.86.48`(22 开放) |
| 本机 Tailscale IP | `100.124.243.30` |
| git 远端 | `git@code.guzhujushi.cn:guzhujushi/MCM-ICM_Toolkit.git` |
### 为什么走 Tailscale
公网 22 端口连不上(`ssh: connect to host code.guzhujushi.cn port 22: Connection timed out`),
但同一台机器的 Tailscale 地址 22 端口是通的,且 `git@` 认证直接通过 Gitea。
所以 `~/.ssh/config` 里加了一条别名把域名指到内网地址:
```sshconfig
Host code.guzhujushi.cn
HostName 100.115.86.48
User git
IdentityFile ~/.ssh/id_ed25519
```
这样 remote 里写的是域名,换机时只改这一段 config,不用动仓库配置。
**推送前先连上 Tailscale**,否则 `git push` 会一直等到超时。
### 自检
```powershell
ssh -T git@code.guzhujushi.cn
# 期望输出:Hi there, guzhujushi! You've successfully authenticated with the key named ...
```
### 日常推送
```powershell
cd D:\MCM&ICM_Toolkit
git add -A
git commit -m "feat(stats): 实现 bootstrap_ci"
git push
```
### 换一台机器 / 加一个协作者
```powershell
# 1) 装 Tailscale 并加入同一个 tailnet
# 2) 生成并上传密钥
ssh-keygen -t ed25519 -C "your@email"
Get-Content "$env:USERPROFILE\.ssh\id_ed25519.pub" | Set-Clipboard
# 粘贴到 Gitea → 设置 → SSH/GPG 密钥
# 3) 写上面的 ~/.ssh/config 别名
# 4) 克隆
git clone git@code.guzhujushi.cn:guzhujushi/MCM-ICM_Toolkit.git
```
不想用 Tailscale 的话,也可以用 HTTPS + Gitea 访问令牌,
代价是每次推送要输凭据(或交给凭据助手缓存):
```powershell
git remote add origin https://code.guzhujushi.cn/guzhujushi/MCM-ICM_Toolkit.git
```
> 注意:`git config --global http.proxy` 目前指向 UniClash(`127.0.0.1:7993`),
> HTTPS 方式会被代理绕一圈;SSH 方式不受该配置影响。
### 仓库归属注意(重要)
`.git` 必须是**你自己的账户**创建的。若用沙箱化的 AI agent 通道代跑 `git init`,
它可能以别的账户身份创建 `.git`,之后你自己跑 git 就会报:
fatal: detected dubious ownership in repository
正确做法是删掉重建(以本人账户):
Remove-Item -LiteralPath .git -Recurse -Force
git init -b main
**不要**用 `git config --global --add safe.directory` 绕过——那只是把问题藏起来,
而且会让 git 在真正的归属异常时也不再警告。
同理,沙箱通道写出的文件 Owner 会是沙箱账户,但权限继承自
`Authenticated Users`(Modify),读写不受影响,只有 git 的归属检查会误报。
### 可见性与协作者
匿名 `git ls-remote` 实测可读 → 仓库当前是 **Public**。要改私有:
仓库设置 → 基本设置 → 可见性。加队友:仓库设置 → 协作者 → 添加成员。
## 四、给队友的说明
队友 clone 后只需要:
```powershell
git clone <你的仓库地址>
cd mcm-icm-toolkit
.\scripts\setup-venv.ps1 # 零下载,复用他们自己的 Anaconda base
.\scripts\doctor.ps1
```
`setup-venv.ps1` 用的是 `--system-site-packages`,所以他们本机得先有 Anaconda。
如果队友没有 Anaconda,让他走 `scripts/setup-conda.ps1`。
---
## 五、回滚
环境升级出问题时的回退手段:
```powershell
# 远端被覆盖过一次(首次推送),那个提交仍在本机对象库里:
# git push origin 1c683cf:refs/heads/restore-initial
# conda 自己保留了 revision 历史
conda list -n base --revisions
conda install -n base --revision <N>
# 代码回滚
git log --oneline
git revert <commit>
```
+22
View File
@@ -0,0 +1,22 @@
# 文档索引
本目录是 MCM/ICM Toolkit 的需求、架构、方案与开发规范的**唯一落盘处**。
| 文档 | 内容 |
|---|---|
| [01-suggestion-review.md](01-suggestion-review.md) | 对 ChatGPT 建库建议的**审查与精简**:留什么、砍什么、补什么 |
| [02-architecture.md](02-architecture.md) | 精简后的包结构、边界划分、统一接口约定 |
| [03-roadmap.md](03-roadmap.md) | 功能清单分级(L0 / L1 / L2 / 不做)与开发纪律 |
| [04-environment.md](04-environment.md) | conda / venv 环境配置、镜像、离线约束 |
| [dev/function-card-template.md](dev/function-card-template.md) | S 档算法的「函数卡片」模板 |
| [05-git-workflow.md](05-git-workflow.md) | Git 工作流、Gitea 远程(SSH 走 Tailscale)、行尾策略 |
| [dev/personal-pitfalls.md](dev/personal-pitfalls.md) | **个人易错点清单:只追加**,追加前不通读 |
## 阅读顺序
1. 先读 `01-suggestion-review.md` —— 它决定后面所有事。
2. 再读 `02-architecture.md` —— 它决定代码往哪写。
3. 开工前读 `03-roadmap.md` 的对应条目 —— 它决定这次做什么、做到什么程度。
4. 环境出问题看 `04-environment.md`。
> 项目级进度看根目录的 `STATUS.md`;本目录只放**不会频繁变**的内容。
+26
View File
@@ -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` | ✅ 2026-10-02 |
| `bootstrap_ci.md` | `stats.bootstrap_ci` | ✅ 2026-10-03(§10 有 5 个口径待拍板)|
| `monte_carlo.md` | `solve.monte_carlo` | ⬜ |
| `monte_carlo_probability.md` | `solve.monte_carlo_probability` | ⬜ |
| `monte_carlo_integral.md` | `solve.monte_carlo_integral` | ⬜ |
| `dynamic_programming.md` | `solve.dynamic_programming` | ⬜ |
| `knapsack_dp.md` | `solve.knapsack_dp` | ⬜ |
| `simulated_annealing.md` | `solve.simulated_annealing` | ⬜ |
| `local_sensitivity.md` | `solve.local_sensitivity` | ⬜ |
| `sobol_analyze.md` | `solve.sobol_analyze` | ⬜ |
| `entropy_weights.md` | `decision.entropy_weights` | ⬜ |
| `topsis.md` | `decision.topsis` | ⬜ |
| `ahp_weights.md` | `decision.ahp_weights` | ⬜ |
| `gm11_fit_predict.md` | `decision.gm11_fit_predict` | ⬜ |
> 卡片是写给自己半年后看的,不是给评审看的。
> 如果一张卡片最后只复述了 docstring,说明这个函数其实是 A 档,应该降档删卡片。
+127
View File
@@ -0,0 +1,127 @@
# bootstrap_ci
| 项 | 内容 |
|---|---|
| 模块 | `mcm_toolkit.stats.bootstrap` |
| 档位 | S |
| 参考来源 | Efron (1979);Efron & Tibshirani (1993) *An Introduction to the Bootstrap*;
| | `scipy.stats.bootstrap`(只做对照,没有调用) |
| 状态 | 实现已落地(2026-10-03),§10 有 4 个口径待拍板 |
## 1. 数学定义
给定样本 `x_1..x_n` 与统计量 `T(·)`:从 `x` 中**有放回**抽 `n` 个得到 `x*`,重复 `B` 次,
得 `T*_1..T*_B`。百分位法区间
```text
estimate = T(x) # 用**原样本**,不是重采样分布的均值
alpha = 1 - confidence
low = T*_(alpha/2) # 经验分布的 alpha/2 分位
high = T*_(1 - alpha/2)
```
输入是一个一维样本,输出是「点估计 + 区间」两个标量 —— 这是**非参数**区间,
不假设正态、也不需要 `T` 的标准误解析式。
## 2. 适用场景
**用**:`T` 没有解析分布(中位数、极差、自定义指标);样本有偏或分布未知;
想给区间但不想假设正态(`mean ± z·s/√n` 的替代品)。
**不用**:`n < 10` —— 经验分布太粗,`x` 能重采样出的不同样本极少;
数据有**强时间相关性**(股票、气温),此时必须用 block bootstrap;
要估的是**极值**(最大值本身就是上界,见 §5)。
## 3. 参数与返回值
| 参数 | 类型 | 默认 | 含义 | 取值影响 |
|---|---|---|---|---|
| `x` | Any | — | 样本;走 `core.as_array(x, numeric_only=True, ndim=1)` | DataFrame 只取数值列,再摊平 |
| `statistic` | Callable | `np.mean` | 任意 `f(sample) -> float` | 换 `np.median` 就得到中位数的区间 |
| `confidence` | float | 0.95 | 置信水平 | 越大区间越宽 |
| `n_resamples` | int | 10000 | 重采样次数 `B` | 越大端点越稳;10000 通常够 |
| `method` | str | `"percentile"` | 区间算法 | `"bca"` 尚未实现,传了抛 `NotImplementedError` |
| `random_state` | int \| None | `None` | 随机种子 | `None` 时复用 `core.get_rng` 的模块级生成器且会推进 |
返回 `Result`:
| 字段 | 内容 |
|---|---|
| `params` | `estimate` / `low` / `high` / `confidence` / `n_resamples` |
| `metrics` | `statistic_name`(`np.mean` → `"mean"`;lambda → `"custom"`)|
| `data` | 不使用 |
## 4. 复杂度
- 时间:`O(B · n)`(`B` 次重采样,每次都要在 `n` 个值上算一次 `T`)
- 空间:`O(B)` —— **按块处理**,每批 `batch_size = 1000` 行,绝不落地 `B × n` 的整矩阵
- **什么时候会跑不完**:
- `n = 1e4, B = 1e4` → `1e8` 次操作,秒级,正常;
- `n = 1e6, B = 1e4` → `1e10` 次操作,**跑不完**;而且 1000×1e6 的单批索引矩阵就要 8 GB,
此时应改用解析近似,或把 `B` 降到 1e3 以下、换更便宜的 `T`。
## 5. 数值稳定性
- **统计量有上界时,百分位区间会「塌」到上界。** 极差 `max - min` 的重采样值**永远不可能超过**
原样本极差,`B` 个值里有相当比例正好等于它(抽到原样本极值点的那些),
于是 `high == estimate` 精确成立。这不是 bug,是 bootstrap 的固有性质 ——
区间端点是**样本**的经验分位数,不能给出样本之外的结论。要估极值请用极值理论。
- 分块只是省内存,不改结果:每批都从同一个 `rng` 按顺序抽索引,等价于一次抽 `B × n`。
- 随机数**只来自 `core.get_rng`**(决策 24):给了种子可复现,不给则跟随 `set_seed`。
- **NaN 目前不处理**:`x` 里的 NaN 会直接进 `T`,`np.mean` 于是给出 `nan` 区间。见 §10。
## 6. 常见错误
1. 不写 seed → 每次跑出来的区间都不一样,论文里的数字对不上 → 传 `random_state` 或先 `set_seed`。
2. 以为 bootstrap 能得出**比样本更极端**的结论 → 区间端点就是样本的经验分位数,
"95% 置信上限 173.2cm" 不可能超过样本最大值。
3. 拿它估最大值 / 最小值 → 区间会塌成一个点(原因同 §5),该换极值理论。
4. `n < 10` 硬跑 → 能抽出的不同重采样样本太少,区间没有统计意义。
5. 把 `n_resamples` 当成"样本量"写进论文 → 论文里要同时写清 `n`、`B`、seed、method。
## 7. 最小示例
```python
import numpy as np
from mcm_toolkit.stats import bootstrap_ci
r = bootstrap_ci(heights, statistic=np.median, n_resamples=10000, random_state=42)
print(r.params["low"], r.params["estimate"], r.params["high"])
print(r.summary()) # 可直接贴进论文的 Markdown 表
```
## 8. 单元测试要点
| 测什么 | 期望 | 对应测试 |
|---|---|---|
| 大样本正态,`statistic=np.mean` | 区间半宽 ≈ `2 × 1.96 × se`,容差 10% | `test_bootstrap_ci_matches_normal_theory_on_large_n` |
| 任意统计量(中位数,对数正态样本) | 区间包含样本中位数 | `test_bootstrap_ci_works_for_arbitrary_statistic` |
| 同种子两次 | `low` 完全相等 | `test_bootstrap_ci_is_reproducible` |
| 不给种子 | 跟随 `set_seed`(决策 24) | `test_bootstrap_ci_follows_get_rng_when_no_seed_is_given` |
| `estimate` | 等于原样本上的 `T(x)` | `test_bootstrap_ci_reports_the_original_estimate` |
| `confidence` 0.80 → 0.95 | 区间单调变宽 | `test_bootstrap_ci_interval_widens_with_confidence` |
| 极差作为统计量 | `high <= estimate`(原子点塌陷) | `test_bootstrap_ci_works_for_a_custom_lambda` |
| 五种容器 | DataFrame/Series/ndarray/list/tuple 结果一致 | `test_bootstrap_ci_accepts_the_documented_containers` |
| `method` 非法 / `"bca"` | `ValueError` / `NotImplementedError` | `test_bootstrap_ci_rejects_unknown_method` |
## 9. MCM/ICM 用法
- **哪类题**:任何要"给结论 + 给不确定性"的题 —— 评价类(排名稳定性)、预测类(区间预测)。
- **论文里怎么表述**:"采用非参数 bootstrap(`B = 10000`,`seed = 42`)估计中位数的 95% 置信区间。"
- **图**:预测曲线 + 阴影置信带(`viz.plot` / `viz.save_figure`)。
- **踩坑提醒**:必须报 `B` 和 seed;说清用的是 percentile 还是 BCa(目前只有 percentile);
被问到"为什么不用 ±1.96σ"时就答"统计量是中位数,没有解析标准误"。
## 10. 待拍板(未定稿,Codex 不代选)
| # | 口径 | 现状 | 可选 |
|---|---|---|---|
| 1 | `x` 里的 NaN | 静默传进 `T` → `nan` 区间 | 像 `confidence_interval` 一样丢掉 / 直接报错 / 维持现状 |
| 2 | `n_resamples <= 0` | 走到 `np.percentile` 抛 `IndexError: index -1 is out of bounds` | 入口 `ValueError(必须 ≥ 1)` |
| 3 | `confidence` 越界 | 由 numpy 抛 `ValueError: Percentiles must be in the range [0, 100]` | 入口 `ValueError + 可读文案` |
| 4 | `method="bca"` | docstring 承诺过,实测 `NotImplementedError` | 补 BCa 实现(S 档,约 25 行)/ 从 docstring 删掉 |
| 5 | `bootstrap_statistic` | 仍是 `NotImplementedError`,而 `bootstrap_ci` 把重采样循环内联了 | 抽出去让 `bootstrap_ci` 调它(易错点 16 的变体) |
> 口径 5 尤其值得做:`bootstrap_statistic` 的 docstring 与 `bootstrap_ci` 里那段循环说的是同一件事,
> 现在等于把同一段算法写了两遍,只实现了一遍。
+172
View File
@@ -0,0 +1,172 @@
# detect_outliers
| 项 | 内容 |
|---|---|
| 模块 | `mcm_toolkit.data.clean` |
| 档位 | S |
| 参考来源 | Tukey (1977) *Exploratory Data Analysis*(1.5·IQR 围栏的出处);3σ 准则见 NIST/SEMATECH e-Handbook §1.3.5.17;实现直接调用 `pandas.DataFrame.quantile` / `mean` / `std` |
| 实现状态 | 已实现(2026-10-02),`tests/test_data.py` 中 9 条用例覆盖 |
## 1. 数学定义
设某列数值样本 `x_1..x_n`,记 `Q_1 = quantile(0.25)`、`Q_3 = quantile(0.75)`,
四分位距 `IQR = Q_3 - Q_1`。对每个 `x_i` 给出布尔判定:
**IQR 法**(默认):
```text
m_i = (x_i < Q_1 - k·IQR) or (x_i > Q_3 + k·IQR) 默认 k = 1.5
```
**z-score 法**:
```text
z_i = (x_i - x̄) / s s 为样本标准差(ddof = 1)
m_i = |z_i| > t 默认 t = 3.0
```
输入:一张表(`DataFrame`)或一维序列;输出:**与原数据同形的布尔掩码**,
`True` 表示该位置是离群点。数学上它是逐元素定义的二值函数,不依赖行与行之间的关系。
## 2. 适用场景
**用**:
- 数据录入错误(身高 260 cm、年龄 −10)这类"明显不可能"的点,先标出来给人看。
- 建模前的敏感性分析:删与不删各跑一遍,看结论动不动 —— 这比直接删更有说服力。
- 论文要交代"异常值怎么处理"时,这是最容易被评审接受的判据(有文献、可复现、无随机性)。
**不用**:
- **别拿它当自动清洗**。偏态、幂律、小样本(`n < 20`)时 IQR 围栏会误杀 ——
重尾数据里"离群"常常就是信号本身。
- z-score 法**前提是近似正态**。右偏数据(收入、人口、成绩分布)用它会把整片上尾标红。
- 时间序列里的"离群"可能来自趋势或季节项,应先去趋势/差分再判,否则会把拐点当异常。
- 多变量离群(单变量看都正常、组合起来才异常)它看不出来,那要用马氏距离或 `IsolationForest`。
## 3. 参数与返回值
| 参数 | 类型 | 默认 | 含义 | 取值影响 |
|---|---|---|---|---|
| `data` | `DataFrame` / `Series` / `list` / `tuple` / `ndarray` | 必填 | 待判数据 | 非数值列不参与统计,掩码里恒 `False`(见下) |
| `method` | `"iqr"` / `"zscore"` | `"iqr"` | 判据 | IQR 对重尾稳健;zscore 只在近似正态时可靠 |
| `k` | `float` | `1.5` | 仅 IQR:围栏倍数 | 1.5 是 Tukey 的 "outlier"、3.0 是 "far outlier";调小 → 标出更多 |
| `threshold` | `float` | `3.0` | 仅 zscore:\|z\| 阈值 | 3.0 ≈ 正态下的 0.27%;调小 → 标出更多 |
`k` 与 `threshold` 通过 `**kwargs` 传入,**传错了不会报错**(`k` 只在 iqr 分支被读)。
### 为什么它不返回 `Result`
模板里"返回 `Result`"这一节对它**不适用**,这是刻意的:`Result` 承载的是"一次计算的
参数 + 指标 + 数据",而 `detect_outliers` 不改数据、不产生标量指标,它只是一个逐元素的
布尔判断。包成 `Result` 会多一层壳,调用方反而要写 `r.data` 才能拿到掩码。
返回值按输入形状分派:
| 输入 | 输出 |
|---|---|
| `DataFrame` | 同形布尔 `DataFrame` |
| `Series` | 布尔 `Series`,**列名保留** |
| 一维 `list` / `tuple` / `ndarray` | 一维布尔 `ndarray` |
| 二维 `ndarray` | 同形布尔 `DataFrame` |
### 非数值列为什么恒 `False`
两条备选路都被否掉了:
- **跳过**(把非数值列从掩码里去掉)→ docstring 承诺的"与原数据同形"就是假话,
形状对不上,也就没法按位置把掩码和原表对回去;
- **报错** → 一张带 `city` 列的真题表直接不可用,而 `city` 列本来就不该影响数值列判定。
`False` 的读法是"这一列没有离群点",对文本列而言这是最不坏的解释,
而且让整表能一次跑完。**但要注意**:这只是"不报错",
不代表文本列可以当数值列进模型 —— 编码是 `one_hot_encode` 的事。
## 4. 复杂度
- 时间:`O(n·p)`(n 行、p 列;分位数和均值各扫一遍,比较再扫一遍)。
- 空间:`O(n·p)` —— 掩码与原表同形,这是主要开销。
- **什么时候会跑不完**:单机几千万行以上时,同形掩码会把内存吃满
(1e7 行 × 20 列 `float64` 约 1.6 GB,掩码再来一份 `bool` 约 0.2 GB,
但 `quantile` 内部还会再复制)。真到那一步应该分块流式处理,而不是加内存。
## 5. 数值稳定性
- **`std == 0`(整列同值)**:代码里先 `std.replace(0, np.nan)`,让该列 z 全为 NaN、
一律判 `False`。少这一步会得到 `inf` / `0/0` 并伴随 RuntimeWarning。
- **NaN**:比较结果是 `False`,所以缺失值**不会**被标成离群。这是有意的 ——
缺失归 `clean_missing` / `impute_missing` 管,两件事不该混。
- **`n = 1`**:`std` 是 NaN、分位数等于该值本身,掩码全 `False`。小样本别指望它。
- **边界值**:`x_i` 恰好等于围栏值时**不算**离群(用的是严格 `<` / `>`)。
这是刻意选的:边界值不该被当异常。
- **`ddof` 口径**:本函数用 pandas 的 `Series.std()`,默认 `ddof = 1`(样本标准差),
而 `numpy.std` 默认 `ddof = 0`(总体标准差)。小样本下差别不小:`[1,2,3,4,1000]` 的
std 是 446.1(ddof=1)对 399.0(ddof=0),z 值直接差一成。**本项目 `stats.zscore` 的默认
也是 `ddof = 0`**,两处口径不一致是已知问题 —— 论文里报 |z| 时必须写明用的哪一个。
(分位数这边反而没问题:pandas `quantile` 与 `numpy.percentile` 默认都是线性插值,结果一致。)
## 6. 常见错误
1. 把返回值当筛选结果:`df = detect_outliers(df)` → 表变成布尔掩码。
正确做法是 `mask = detect_outliers(df)`,然后**自己决定**删不删。
2. 对含文本列的真题表直接调用 → 老版本在 `quantile()` 上崩。现在非数值列恒 `False`,
但要记得"不崩"不等于"文本列进了模型"。
3. 用 z-score 判右偏数据 → 整片上尾被标红,删完结论就变了。先画分布再选方法。
4. 看到离群就删 → 论文里没有交代、评审必问。先报告个数与占比,再做删/不删对照。
5. 只报"删了 5 个" → 没给比例、没给判据、没给 `k`。论文里这三个数缺一不可。
6. 以为要设 seed → 本函数**不含随机成分**;但后续若用 bootstrap 比较"删前后"的差异,
那条链必须 `set_seed`,否则"可复现"是假的。
## 7. 最小示例
```python
import numpy as np
import pandas as pd
from mcm_toolkit.data import detect_outliers
df = pd.DataFrame({"city": list("abcd"), "v": [1.0, 2.0, 3.0, 1000.0]})
mask = detect_outliers(df) # IQR,默认 k=1.5;只标不删
print(mask)
# city v
# 0 False False
# 1 False False
# 2 False False
# 3 False True
print(np.asarray(mask).sum()) # 1
print(detect_outliers(df["v"]).tolist()) # Series 进 Series 出
# [False, False, False, True]
# 想改成更激进的判定:k 调小
print(np.asarray(detect_outliers(df, k=0.5)).sum())
```
## 8. 单元测试要点
| 测什么 | 期望 |
|---|---|
| 埋 3 个远离主体的点(IQR 默认) | 正好命中 3 个,且位置对得上 |
| 埋 1 个点(zscore 默认 t=3) | 正好命中 1 个 |
| 返回形状 | 与输入同形;Series 进 Series 出且列名保留;一维数组进一维布尔出 |
| 含文本列的表 | 不崩,文本列恒 `False`,数值列照常判定 |
| 整表没有数值列 | 不崩,返回全 `False` 掩码 |
| `method="bogus"` | `ValueError`,报错信息里带方法名 |
| `data = 42 / 3.14 / "123" / None / dict` | `TypeError`(§4.7:非法类型不静默兜底) |
| 全 NaN 列、单元素列 | 全 `False`,不抛异常 |
| 元组入参 | 按一维数组型处理,结果与传 list 一致 |
## 9. MCM/ICM 用法
- **哪类题**:所有要写"数据预处理"小节的题(几乎全部)。评价类与预测类尤其要做,
因为离群点对均值、回归系数的影响最大。
- **论文里怎么表述**:
"采用 Tukey (1977) 的 1.5×IQR 围栏识别异常值:记 Q1、Q3 为第一、三四分位数,
凡落在 [Q1 − 1.5·IQR, Q3 + 1.5·IQR] 之外者标为可疑点,共 N 个(占 x%)。
我们保留全部原始数据,并给出剔除/不剔除两版对照结果(见附录 B)。"
- **图**:箱线图(标出被标红的点)+ 直方图叠围栏竖线;用 `viz.plot(kind="box")`
与 `viz.plot(kind="hist")`,保存用 `viz.save_figure`。
- **踩坑提醒**:评审最可能追问三件事 ——
① 为什么是 1.5 而不是 3?② 删了多少、删完结论变了吗?③ 这些点是"错误"还是"真实极端值"?
三个问题都要在论文里先答,别等被问。
+18
View File
@@ -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 放进依赖**——它是可选的、和比赛无关的工具。
+137
View File
@@ -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 结果不同时要说明选了哪个及理由。
```
+562
View File
@@ -0,0 +1,562 @@
# 个人易错点(只追加)
> **本文件的规矩**
> 1. **只追加。** 已有条目一个字都不改、不重排、不润色。发现写错了 → 在末尾补一条"更正",不要回头改。
> 2. **不通读。** 追加前只做两件事:`rg -i "<关键词>" docs/dev/personal-pitfalls.md` 看有没有记过;
> 看一眼下面的索引表最后一行拿编号。全文只在复盘时才读。
> 3. 一条写三行:**现象** / **为什么会错** / **以后怎么防**。
> 写不出"为什么会错"就先别记——那说明还没想清楚。
> 4. 记**规律**,不记事故。"今天又忘了"不新开条目,在已有条目下把 `复发` 加一。
## 追加模板
```markdown
### N. <一句话>
- 现象:
- 为什么会错:
- 以后怎么防:
- 首次:YYYY-MM-DD|复发:0
```
## 索引
| # | 一句话 | 首次 |
|---|---|---|
| 1 | 类型判断只写 `int`,漏掉 `np.int64` 这类 numpy 标量 | 2026-10-01 |
| 2 | 以为 `# TODO(L0)` 只是普通注释 | 2026-10-01 |
| 3 | 签名里的类型注解和 docstring 说的不一致 | 2026-10-01 |
| 4 | 只覆盖自己的状态,忘了第三方库还在用 numpy 旧全局 | 2026-10-01 |
| 5 | 参数校验排在副作用(建目录 / 写文件)之后 | 2026-10-01 |
| 6 | 把 lint 的"没报错"当成"没问题" | 2026-10-01 |
| 7 | 写"守卫子句"时,忘了不是所有入参都长一样 | 2026-10-02 |
| 8 | 改了一处,忘了同源的另一处 | 2026-10-02 |
| 9 | 实现写完了,测试还挂着 skip —— 绿灯是假的 | 2026-10-02 |
| 10 | 把真报错当成"编辑器误报" | 2026-10-02 |
| 11 | pandas 对不齐就静默填 NaN,不报错 | 2026-10-03 |
| 12 | 三个并列的 `if`,后面的静默覆盖前面的 | 2026-10-03 |
| 13 | `Index == 标量` 得到布尔数组,`if` 要等到第 2 列才炸 | 2026-10-03 |
| 14 | 把"能看的表"和"能算的表"塞进同一个返回值 | 2026-10-03 |
| 15 | 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 参数写反 | 2026-10-03 |
| 16 | 同一段入口转换抄了 N 遍,还当它是"本项目的写法" | 2026-10-03 |
| 17 | `hasattr(f, "__name__")` 挡不住 lambda,`else` 分支是死代码 | 2026-10-03 |
| 18 | 入口校验"想起什么写什么",没照 §4.7 清单逐条过 | 2026-10-04 |
| 19 | 只想着主路径那种值,忘了布尔 / NaN / 长度不对 | 2026-10-04 |
| 20 | "不简单"的信号:同一个信息要在调用方写两遍 | 2026-10-04 |
| 21 | 公式里的 `^` 不是乘方,而且被 `except` 吞成 success=False | 2026-10-05 |
| 22 | 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功" | 2026-10-05 |
| 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 |
| 24 | 默认参数路径从没被执行过 —— 默认采样器用了库不接受的参数 | 2026-10-05 |
| 25 | 两个维度相等时,转置的形状检查会静默通过 | 2026-10-05 |
| 26 | `except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸 | 2026-10-05 |
| 22 | 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功" | 2026-10-05 |
| 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 |
---
### 1. 类型判断只写 `int`,漏掉 `np.int64` 这类 numpy 标量
- 现象:`get_rng(np.int64(42))` 不报错,但种子被静默忽略、退回全局流——结果不可复现,且没有任何提示。
- 为什么会错:`np.int64` **不是** python `int` 的子类(`isinstance(np.int64(42), int)` 是 `False`),
但 `np.float64` **是** `float` 的子类。numpy 标量的继承关系不统一,凭直觉一定会翻车。
- 以后怎么防:写类型分支时默认假设"这个值可能来自 numpy"。判断用 `isinstance(x, (int, np.integer))`,
取值统一 `int(x)` 归一化;并且给所有非预期类型加 `raise TypeError` 兜底——
**宁可报错,也不要静默走默认分支**。
- 复发(2026-10-04):`monte_carlo` 的 `n` 只写了 `isinstance(n, int)` —— `np.int64(1000)` 被 `ValueError` **误拒**,
而 `n=True` 反而放行(`bool` 是 `int` 子类,判断顺序又反了)。
同一张 §4.7 表的第一、三行,症状从"静默兜底"变成"误拒 + 误收":**规则没换,只是没去查**(见第 18 条)。
- 首次:2026-10-01|复发:0 → **1(2026-10-04)**
### 2. 以为 `# TODO(L0)` 只是普通注释
- 现象:函数实现完了,`# TODO(L0)` 还挂在末尾。
- 为什么会错:本仓库把 `TODO(L0)` / `TODO(L1)` / `TODO(v0.2)` 当**进度标记**用:
`docs/03-roadmap.md` 靠它划分档位,`STATUS.md` 靠它统计"还剩哪些没做"。实现完不删,等于骗自己。
- 以后怎么防:实现完成时顺手删掉该函数里的 `TODO(...)`。
全仓还剩多少没做,一条 `rg "TODO\(" src/` 就能数出来。
- 首次:2026-10-01|复发:0
### 3. 签名里的类型注解和 docstring 说的不一致
- 现象:`def get_rng(random_state: int | None = None)`,docstring 却写"已经是 `np.random.Generator` 时原样返回"。
- 为什么会错:类型注解是给**读代码的人和 pyright** 看的契约。注解说只收 int/None、docstring 说还收 Generator,
两处打架时合法用法会被 IDE 标红,真正的错误反而看不出来。
- 以后怎么防:改签名和改 docstring 必须同一次做完。
本项目所有 `random_state` 的注解统一写 `int | np.integer | np.random.Generator | None`。
- 复发(2026-10-05):`monte_carlo_probability` 写 `event: Callable[..., bool]`,docstring 却说"返回长度为 `n` 的**布尔数组**"
—— 注解只描述"返回一个 bool"。pyright 实测:这样写会**拒绝**任何注解良好的向量化事件函数(`ndarray` 与 `bool` 不兼容),
而运行期它一直都收得下。同一个坑从"收什么"换到了"返回什么":**注解是契约,不是备注**。
- 首次:2026-10-01|复发:1 → **2(2026-10-05)**
### 4. 只覆盖自己的状态,忘了第三方库还在用 numpy 旧全局
- 现象:`set_seed` 换掉了本包的生成器,但 sklearn / statsmodels 里 `random_state=None` 的路径
仍不可复现(测试直接挂)。
- 为什么会错:numpy 有**两套**随机状态——旧的 `np.random.*` 全局单例,和新的 `Generator`。
第三方库大量仍在走旧的那套(`check_random_state(None)`),自己新建一个 Generator 管不到它们。
- 以后怎么防:凡是"设全局状态"的函数,先列清单——自己的、numpy 旧的、标准库 `random` 的、
第三方框架的,逐个决定要不要一起设,并在 docstring 里写清到底覆盖了哪几个。
- 首次:2026-10-01|复发:0
### 5. 参数校验排在副作用(建目录 / 写文件)之后
- 现象:`save_table` 先 `path.parent.mkdir(parents=True, exist_ok=True)`,再在
`if suffix == '.csv' ... else: raise ValueError` 里判断后缀。传 `sub/x.txt` 时,
空目录 `sub\` 已经建出来了,函数才抛 `ValueError`——错误路径上留下垃圾。
- 为什么会错:函数体是照着"顺利流程"顺着写下来的——先建目录、再按分支干活;
校验顺手塞进分支的 `else` 里,于是校验自动排到了最后。分支越多,越会有路径漏掉。
会传染:`isinstance` 校验改到 `mkdir` 前面之后,后缀校验**还是**留在原地,
因为它藏在分支里,看起来"已经检查过了"。
- 以后怎么防:函数体分三段写——**① 所有校验 → ② 所有副作用(建目录 / 写文件 /
联网 / 改全局状态)→ ③ 主逻辑**。校验用守卫子句集中在开头,不要塞进分支里;
凡是 `raise` 的行,位置必须在校验段内,不能出现在副作用段之后。
写完自问一句:"这个参数不合法时,磁盘 / 网络 / 全局状态会不会已经动过了?"
- 首次:2026-10-01|复发:0
### 6. 把 lint 的"没报错"当成"没问题"
- 现象:`__all__` 与下一个 `def` 之间只留了 1 个空行(PEP 8 要求 2 个),
`ruff check src tests` 照样打印 "All checks passed!"。
- 为什么会错:版本号看着一样,覆盖的规则却是两回事。ruff 0.16 里 `E302`
(函数间空行)属于 **preview 规则**,不在 `select = ["E"]` 的默认集合里——
加了也会被"because preview is not enabled"忽略掉,安安静静不报。
根本问题是:**lint 只检查你 select 的那部分**,而默认规则集本来就不是 PEP 8 全集。
把"我配的检查全过了"读成"这份代码符合规范",是把自己没开的检查当成了已经通过的检查。
- 以后怎么防:把 lint 结论限定成"我 select 的规则里没问题",别说成"没问题"。
想看全貌就偶尔跑一次 `ruff check --select ALL --preview`(只当参考,别直接开进 CI,
会淹没在噪声里);纯格式问题交给 `ruff format`,它是 formatter,不受 `select` 影响。
- 首次:2026-10-01|复发:0
### 7. 写"守卫子句"时,忘了不是所有入参都长一样
- 现象:`winsorize` 开头先算 `numeric_cols = data.select_dtypes(include=['number']).columns`
挡"空数值列",但 `winsorize` 同时收 `DataFrame` 和 `Series` ——
**`Series` 上没有 `select_dtypes`**,传 Series 直接 `AttributeError`,
而且报错就报在守卫那一行(全函数看起来最"安全"的地方)。
- 为什么会错:写的时候只想"要防住空数值列",顺手把守卫放在函数最上面,
没回头确认**这个函数还收哪些类型**。守卫本身没错,错的是它的**位置**:
它对 `DataFrame` 成立、对 `Series` 根本不合法,所以必须排在
`isinstance(data, pd.Series)` 的类型分派**之后**。
本质是:**先做类型分派,再写只对其中一支成立的逻辑**。
- 以后怎么防:写"开头集中校验"之前,先把入参的**全部类型**列一遍,
逐条问"这句话对每种类型都成立吗";不成立的往后挪,
挪进 `if isinstance(...)` 里面,让它只有对应的那一支能走到。
- 首次:2026-10-02|复发:0
### 8. 改了一处,忘了同源的另一处
- 现象:给 `detect_outliers` 加"文本列不参与"的修复时,只把**掩码写入**收窄到数值列
(`mask[numeric_cols] = ...`),却让 `data.quantile()` / `data.mean()` / `data.std()`
继续在全表上算。测试照样红 —— 文本列在**算统计量**那一步就崩了,跟掩码怎么写没关系。
- 为什么会错:眼睛盯着"结果往哪放"(掩码),没回头问"这些数是从哪儿来的"。
同一份数据在函数里要过两道加工:**先出统计量(分位数 / 均值 / 标准差),再拿去比较**。
只把后一道收窄、前一道还是全表,等于没修。
修完测试报告里那条仍是 XFAIL,就是这个信号 —— 报告没变绿,说明根因没抓着。
- 以后怎么防:修"某处不该参与计算"这类问题时,顺着数据从入到出**走一遍**,
把该收窄的地方一次列全,别改完第一处就收手;
并且以测试信号(XFAIL → XPASS)为准,别以"我改的那一行看起来对了"为准。
- 首次:2026-10-02|复发:0
### 9. 实现写完了,测试还挂着 skip —— 绿灯是假的
- 现象:`transform.py` 四个函数都写完了,`pytest` 一片绿(`64 passed`),
但 `test_normalize_minmax_range` / `test_standardize_has_zero_mean_unit_std` 两条
还挂着 `@pytest.mark.skip(reason=_NOT_IMPLEMENTED)` —— 新代码**一条测试都没跑到**。
顺带还漏掉一个真错:`normalize` 少了兄弟函数都有的类型守卫,`normalize(None)` 静默返回 NaN。
- 为什么会错:`skip` 是"还没写"的意思,写完实现后该由**写的人**把它摘掉;
但摘 mark 这个动作不在"写函数"的肌肉记忆里,跑一次 `pytest` 看到全绿就以为收工了。
这是决策 26 埋下的反面:逐条 skip 比模块级 skip 精确,但**数量得自己盯**。
- 以后怎么防:每次写完实现,用 `pytest -q -rs`(或 `--collect-only`)**核对 skip 条数掉没掉**;
条数没变就说明实现没被验证过。另一个信号是 `pytest --collect-only` 的收集数 ——
新写的测试没进收集列表,就是名字或位置不对。
- 复发(2026-10-03):`bootstrap_ci` 写完了,`tests/test_stats.py` 里等它的 3 条仍然挂着 skip,
全量照样 134 passed / 37 skipped 全绿 —— 连 `_NOT_IMPLEMENTED` 这个常量名都没提示你。
- 首次:2026-10-02|复发:0 → **1(2026-10-03)**
### 10. 把真报错当成"编辑器误报"
- 现象:`transform.py` 改完后编辑器(Pylance)在 `pd.DataFrame(result, columns=x.columns,
index=x.index)` 两行画红线,第一反应是"静态检查又误报了,挂个 `# type: ignore` 就完了"。
实际跑一遍:**pyright CLI 报同样的 2 个错**(Pylance 就是 pyright 的内核),
而且 `pytest` **7 条红**(`normalize` 的三种 method 全走错、`one_hot_encode` 把编码列整列丢掉)。
红线只是**症状**,病在代码里。
- 为什么会错:编辑器把错误显示在**它看得见的那一行**,而那一行的类型确实和来源不符;
但"类型不符"既可能是检查器保守(误报),也可能是**上游真的算错了**。
不验证就下"误报"结论,等于用一个假设替代一次检查。
- 以后怎么防:见到红线先做两件事,再谈 `# type: ignore` ——
① 在**终端**跑同引擎的 CLI(`python -m pyright`),排除编辑器缓存/配置差异;
② 跑 `pytest`,看对应的行为断言红不红。两条都过、确认只是检查器保守推断,
才在**最小范围**加 `# type: ignore[具体错误码]`;裸 `# type: ignore` 会连带关掉后面所有行
(更好的写法见 `docs/04-environment.md` 坑 8 的 `cast`)。
- 首次:2026-10-02|复发:0
### 11. pandas 对不齐就静默填 NaN,不报错
- 现象:`describe` 里为了"行 = 变量、列 = 指标"多做了一次 `.T`,然后
`table.loc["missing"] = df_num.isna().sum()`。跑起来**一声不响**,表里却多出四行
**全 NaN**(`missing` / `missing_ratio` / `skewness` / `kurtosis`)。
`.loc[新行标签] = Series` 是按**列名**对齐的:Series 的索引是变量名、表的列名是
指标名,两边**没有一个交集**,pandas 于是把整行填成 NaN —— 不抛异常。
同一个 `.T` 让 `weights` 分支换成 ndarray 后反而直接报
`ValueError: cannot set a row with mismatched columns` —— 那个报错才是救命的。
- 为什么会错:脑子里想的是"给这一行赋值",pandas 做的是"按标签对齐再赋值"。
对齐失败在 pandas 里是**合法结果**(就是缺失),不是错误 —— 它不会替你判断
"这次对齐失败是不是手滑"。
- 以后怎么防:① 往表里加一行前,先确认 Series 的索引 == 目标轴的标签(列名),
拿不准就 `print(list(series.index))` 对着 `list(table.columns)` 看一眼;
② 新加的行/列**立刻断言不是全 NaN**,别只看"代码跑过去了";
③ 记住这次的信号差异:长度不对(ndarray)会报错,Series **不会** —— 后者更危险。
- 首次:2026-10-03|复发:0
### 12. 三个并列的 `if`,后面的静默覆盖前面的
- 现象:`normalize` 里三种 method 写成了**三个并列的 `if`**(而不是 `if / elif / else`):
`if method == "minmax": result = ...`、`if method == "vector": result = ...`,
末尾再来一句无条件的 `result = arr / x_max` 兜住 max。
读起来像"三种方法各算各的",实际是**三段顺序执行、逐次覆盖**同一个 `result`:
不管传 minmax 还是 vector,算完都被最后那段冲掉 —— 三种方法**实际只走了 max**。
docstring 里那行 `>>> normalize([1, 2, 3]).tolist()` → `[0.0, 0.5, 1.0]` 就是活规格,
实测出来是 `[0.33, 0.67, 1.0]`,5 条测试一起红。
- 为什么会错:脑子里想的是"按 method 分派",手上写成了"按顺序执行"。
写第一个 `if` 的时候没意识到"少了 `elif` 就没有互斥",而 `result` 又是同一个变量 ——
`if` 只会让**条件为真时**赋值,`elif`/`else` 才负责让**其余分支不执行**。
这类错**不报错、不抛异常**,只有断言能抓。
- 以后怎么防:① 同一个变量被多个分支赋值时,一律 `if / elif / else`,或让每个分支 `return`,
绝不写并列 `if`;② 分派逻辑写完先跑最朴素的那个用例(docstring 的 Examples 就是最好的靶子),
别等全量测试;③ 注意 ruff 的 `RET505` 抓的是**反方向**(`return` 后还有 `elif`),
并列 `if` 覆盖它完全管不着 —— **lint 不会替你发现这个**。
- 同一轮的另一个同类例子见第 11 条(`.loc` 对齐失败静默填 NaN):
这两个 bug 的共同点是"**不抛异常**",靠的是断言而不是报错。
- 首次:2026-10-03|复发:0
### 13. `Index == 标量` 得到布尔数组,`if` 要等到第 2 列才炸
- 现象:`correlation_matrix` 里的判空写成了 `if numeric_cols == 0:`,本意是"一个数值列都没有就报错"。
实际行为分成三段,**每一段都不一样**:
- **0 列**:`array([], dtype=bool)` → `bool(...)` 是 `False` → **静默不报错**,带着空表继续往下跑;
- **1 列**:`array([False])` → 单元素数组能转 `bool` → **碰巧正常**;
- **≥2 列**:`array([False, False, ...])` → `ValueError: The truth value of an array
with more than one element is ambiguous` → **直接崩**。
而"≥2 个数值列"恰恰是最常见的情况,所以这一行等于让整个函数不可用。
- 为什么会错:`Index` 重载了 `==`,做的是**逐元素比较**,返回的是**数组**不是 `bool`。
脑子里比的是"这个序列是不是空的",手上写的是"这个序列的每个元素是不是 0"。
更阴的是 1 列时它能跑通 —— 拿单列试一下就会以为这行没问题。
- 没被抓住的原因:对应的 L0 用例当时还挂着 `skip`(见第 9 条),所以实现零覆盖。
- 以后怎么防:① 判"空"一律 `len(x) == 0`(或 `.empty`),绝不用 `== 0`;
② 判断容器里有没有东西,用 `if not len(...)` 而**不是** `if x:` ——
`if x:` 对 `DataFrame` / `Index` / 多元素 `ndarray` 会抛同一个歧义错误;
③ 新写的分支,**至少拿 0 / 1 / 多三种形状各跑一遍**,单列能过不代表多列能过。
- 首次:2026-10-03|复发:0
### 14. 把"能看的表"和"能算的表"塞进同一个返回值
- 现象:`correlation_matrix` 返回的是一张**带显著性星标的字符串表**(`1.000***` 这种),
直接贴论文很爽。但同一个函数在 docstring 里承诺的是"值 = 相关系数",
L0 用例也断言 `np.allclose(np.diag(...), 1.0)` —— 字符串表两样都做不到:
拿到结果的人**没法**挑高相关对、喂给聚类 / PCA、画热力图。
于是这条用例只能先挂 `xfail`,实现和契约顶在那里。
- 为什么会错:写的时候想的是"我最后要看到什么"(论文里的那张表),
没想"调用方还要拿它**算什么**"。展示需求是**输出端**的事,不该侵占返回值。
一旦返回字符串,数值信息就**不可逆地丢了**(你可以随时把数字格式化成字符串,
但没法把 `"0.570***"` 还原成 `0.570` 和小数点后丢失的精度)。
- 以后怎么防:① 一个函数只返回**一种东西** —— 要么是数据,要么是给人看的展示;
② 判据是"这个返回值还能不能参与下一步计算":不能,就该拆成两个函数;
③ 拆的时候让**计算型**当主函数、**展示型**去调它(`correlation_table` 调
`correlation_matrix` + `correlation_pvalues`),而不是反过来。
- 首次:2026-10-03|复发:0
### 15. 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 的参数写反了
- 现象:`confidence_interval` 的返回写成了
`return (float(min(0.0, lower)), float(max(upper, 1.0)))`,本意是"比例区间不能越界"。
三个毛病叠在一起:
① **参数写反**:要夹下界应该写 `max(0, lower)`,写成了 `min(0.0, lower)` —— `0.0` 比任何正的下界都小,
`min` 取到的永远是它,于是**下界恒为 0**;上界那条 `max(upper, 1.0)` 同样是反的,
只是它在 `upper > 1` 时凑巧看不出问题。
② **无条件夹**:三种 method(含 `normal` / `t` 这两种**均值**区间)全被夹。
均值区间的量纲根本不在 `[0, 1]` 里:实测
`confidence_interval(rng.normal(170, 8, 50), method="t")` 的下界是 **0.0**,
而正确结果是 `171.0 ± 2.1`。这种错**不抛异常**,只有把数字打印出来才会发现"身高的下界是 0"。
③ **夹子会吞掉报错信号**:`confidence=1.5` 时 `z` 是 `nan`,`min(0.0, nan)` 返回 `0.0`
(`min` 在遇到 `nan` 之前先锁定了第一个参数),于是本该报错的入参被盖成 `(0.0, nan)`。
另外 `confidence=0` 也一路放行(定义域应是 `(0, 1)` **开区间**)。
- 为什么会错:脑子里记的是"clamp(x, lo, hi)"这个整体,手上却从两个方向各写了一个 `min` / `max`;
更要命的是**试数据选了 `[1, 2, 3, 4, 5]`** —— 它恰好落在 `[0, 1]` 附近,夹不夹都看不出来。
换一个量纲明显不在 `[0, 1]` 的例子(身高 170、GDP 十亿)跑第一遍,这个 bug 一秒现形。
- 以后怎么防:① 夹子只写一个方向:`np.clip(x, lo, hi)`,或 `max(lo, min(hi, x))`;
不要手写两个 `min` / `max` —— 它们看起来对称,其实一个管下界一个管上界,最容易写反。
② **写"区间"函数先做量纲检查**:拿一个边界外的数值当输入,结果被夹到 0 / 1 就是 bug。
③ 边界参数一律按开区间校验(`0 < confidence < 1`),并用 `np.isnan` 兜住参数异常产生的 nan。
④ 记住 `min` / `max` **不传播 nan、结果还取决于参数顺序**(`min(0.0, nan)` → `0.0`、
`min(nan, 0.0)` → `nan`)—— "夹一下"就能把 nan 悄悄换成边界值。
- 首次:2026-10-03|复发:0
### 16. 同一段入口转换抄了 N 遍,还当它是"本项目的写法"
- 现象:`normalize` / `standardize` / `log_transform` / `zscore` / `confidence_interval` / `t_test`
六个函数各自抄了同一段 5~8 行:白名单 `isinstance` → `is_df` / `is_series` → 抓 `columns` / `index`
→ `np.asarray`。这类判断一度散在 **5 个文件 31 处**。抄到第三遍时已经不觉得是重复了,
反而觉得"这就是本项目的入参写法"。
- 为什么会错:重复代码每次只多 3~5 行,看着不痛;但每多一处就多一次**口径分叉**的机会 ——
而且真的分叉了:`normalize` 拿整表 `np.asarray`(遇文本列直接抛)、`zscore` 用 `select_dtypes`
只取数值列、`confidence_interval` 还多一个 `.ravel()`。同一件事三种口径,出 bug 时你还得先判断
"这是哪个副本的行为"。
- 以后怎么防:① 同一段入参处理**抄到第 2 遍**就该抽,别等第 3 遍;
② 抽之前先问一句"输出的形状是不是和输入一样"——**入口能统一、出口不能一刀切**
(`describe` / `correlation_*` 的输出天生不同形,硬套还原会重演第 14 条);
③ 抽出来的函数只干**一件事**:把容器转成数组、并告诉调用方原来是什么。
选列 / 摊平 / 删缺失这些是**语义**,必须留在各函数里(否则重演第 15 条与 `paired=True` 的 NaN 错位)。
- 首次:2026-10-03|复发:0
### 17. `hasattr(f, "__name__")` 判断"是不是具名函数"—— lambda 也有 `__name__`
- 现象:`bootstrap_ci` 想给 `metrics["statistic_name"]` 一个名字,写成了
`statistic.__name__ if hasattr(statistic, '__name__') else "custom"`。
本意是"认得的内置统计量给名字、自定义的标 custom",实际传 lambda 时得到的是
**字符串 `"<lambda>"`** —— 因为 lambda 有 `__name__`,只是值恒为 `"<lambda>"`。
也就是说 `else` 那支**几乎永远走不到**,是死代码。
- 为什么会错:`__name__` 在脑子里等于"函数的名字",于是 `hasattr` 就等于"这是个函数"。
实际上 `__name__` 是**属性存在性**,不是"名字有意义":lambda、`functools.partial` 包过的函数、
`numpy` 的 ufunc(`np.mean.__name__` 是 `"mean"`,能过)各有各的情况,
真正拿不到的只有**没有 `__name__` 的可调用对象**(比如带 `__call__` 的类实例)。
同类陷阱还有 `hasattr(x, "__len__")` 判容器(`str` 也有)、`hasattr(x, "__iter__")` 判列表。
- 以后怎么防:① 判"是不是我想要的那类函数",用**显式白名单**(`if statistic in [np.mean, np.median, ...]`),
别用属性存在性推断语义;② 真要取名字就写成
`name = getattr(f, "__name__", None)` 再判断 `name in (None, "<lambda>")`,
把"没有名字"和"名字是占位符"两种情况都覆盖;③ 每个 `else`/兜底分支都问一句
"**这条到底测得到吗**"——走不到的分支就是 bug 伪装成的防御性代码。
- 首次:2026-10-03|复发:0
### 18. 入口校验"想起什么写什么",没有照着清单逐条过
- 现象:`monte_carlo` 的入口只写了"n 是整数、n 为正",没写"那 `n=True` / `n=np.int64` / `n=10.0` 各走哪条"。
结果 `np.int64(1000)` 被误拒、`n=True` 被放行、浮点 `n` 报的是 `ValueError` 而不是 `TypeError`。
§4.7 那张判断顺序表早就写死了,写代码时一次都没翻。
- 为什么会错:校验是"防御性代码",写的时候满脑子是主路径("次数当然是整数"),
**不会自然想到 `True`、`np.int64`、`None`、0 这些"合法类型里的例外值"**。
清单存在的意义正是替代这种直觉——但清单要主动去查才有用;不查就等于没有。
同类:第 1 条(只写 `int`)、第 7 条(守卫子句的位置)、`random_state=None` 绕开 `get_rng`(决策 46)。
- 以后怎么防:每个公开函数写完,把 §4.7 的表当 checklist **逐行过一遍**
(int + np.integer / bool 排在 int 前 / float + np.floating / 数组先 `np.asarray`),
再用三个探针各打一枪:`f(x=np.int64(1))`、`f(x=True)`、`f(x=1.0)`。
**这一步是本人的活**(决策 49):要不要放宽、报什么错、算不算合法,都是取舍,不是机械活。
- 复发(2026-10-05):`random_walk` 的 `n_steps = 2.5` / `n_steps = True` 又都走 `ValueError("必须是正整数")` ——
同模块的 `monte_carlo` 早就是"类型不对 `TypeError`、取值不对 `ValueError`"。表就在那儿,还是没翻。
- 首次:2026-10-04|复发:**1(2026-10-05)**
### 19. 只想着主路径上的那种值,忘了同一位置还会出现别的类型 / 值
- 现象:`monte_carlo` 的 `simulator` 返回**布尔**数组("事件是否发生",MC 最常见的用法)时,
`np.asarray(...)` 原样保留 bool,`np.std` 在 numpy 2 上直接抛
`numpy boolean subtract ... is not supported`,整个函数判失败;字符串结果则给出
"ufunc isnan not supported"这种看不懂的话。同一轮还漏了:结果里含一个 NaN 时 8 个指标全 NaN 却报 `success=True`。
- 为什么会错:写的时候脑子里只有"年收益 = 浮点数"这一种结果,
"0/1 事件"、"NaN"、"长度对不上"都不在主路径上。而**这个函数恰恰是这几类输入的高发区**
(事件概率、模型发散、批量大小写错),所以它们不是边角料,是常规用法。
- 以后怎么防:写完一个"吃调用方数组"的函数,除了主路径,按五类各跑一次:
**布尔 / 整数 / 含 NaN / 全相同(常量)/ 长度不对**。
类型归一化放在**入口一处**做(`np.asarray(x, dtype=float)`),后面所有指标就都安全了——
这正是 §4.7 表最后一行与 §4.8 存在的理由。
- 首次:2026-10-04|复发:0
### 20. "不简单"的最早信号:同一个信息要在调用方写两遍
- 现象:第一版 `monte_carlo` 的契约是 `sampler(rng, **kwargs)`,但采样器**拿不到 n**,
于是调用方必须写 `n=1000, size=1000` —— 同一个数写两遍,还要靠 `len(data) != n` 事后兜底。
改成实现自己传 `sampler(rng, size=n, **kwargs)` 之后,调用方只写 `n=1000`。
同一处的后遗症:`**kwargs` 里再传 `size` 会撞成 `got multiple values for keyword argument`。
- 为什么会错:设计签名时只想着"参数全靠 `**kwargs` 透传"这一条好处(灵活),
没数"调用方为了让它跑起来还得额外写什么"。
**重复的那一个数是症状,不是问题**:真正的问题是"批量大小"这个信息在接口里**没有归属**。
- 以后怎么防:写完签名,立刻写出**最小调用示例**,然后数两件事:
① 有没有哪个信息写了两遍;② 有没有哪一步"只有我知道"。有 → 把它吸收进实现,而不是写进文档。
简单 ≠ 参数少,而是**调用方不用重复表达**。
- 首次:2026-10-04|复发:0
### 21. 把数学公式抄进代码时,`^` 当成乘方 —— 还被 `except` 吞成 `success=False`
- 现象:`monte_carlo_probability` 的 Wilson 区间里写 `z_score^2 / n`。Python 的 `^` 是**按位异或**,
对 `np.float64` 直接抛 `TypeError: ufunc 'bitwise_xor' not supported`;因为整段计算在
`try/except Exception` 里,对外只表现为 `success=False` + 一句 message ——
最后是 6 条测试全红(`KeyError: 'ci_high'`)才把它挖出来。
- 为什么会错:纸上的 `z²`、`x²` 抄进代码时手会自动敲 `^`(数学记号、Excel、LaTeX 都是这个符号),
而 Python 的乘方是 `**`。更险的是 `^` 在**整数**上是合法异或:`3^2` 得到 1、不报错,只算错。
第二层原因是 `try/except Exception` 把"代码写错"和"数据不合法"混成一个出口,
错误现场(哪一行、哪个异常)被压成一句字符串,静态检查也看不见(pyright 当时 0 error)。
- 以后怎么防:① 公式里的幂一律写 `**`(或 `np.power`),见到 `^` 就当 bug 处理;
② 新函数写完先**手动跑一次正常输入**,`success` 必须是 `True` —— 这条一跑就现形;
③ 给算法配一条**独立实现的对照测试**(Wilson 对 `statsmodels.proportion_confint(method="wilson")`),
这类"公式抄错"会被直接钉死;④ 记住 `except Exception` 越宽,错误越难看见。
- 首次:2026-10-05|复发:0
### 22. 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功"
- 现象:`monte_carlo_integral` 里内层 `monte_carlo` 已经返回 `success=False`(`func` 返回了标量),
但包装层接着无条件执行 `result.message = "蒙特卡洛多重积分成功:…"`。对外就是 `success=False` + 一句"成功"文案。
- 为什么会错:把 `result` 当成"自己的返回值"直接美化,忘了它是**可能失败**的通道。
`success` 和 `message` 是同一份状态的两个视图,分开写就一定有机会不一致 —— 与第 12 条(后面的 `if` 静默覆盖前面的)同源。
- 以后怎么防:包装函数里只改"成功才会有意义"的字段(`name` / `params` / `metrics`),改 `message` 一律包在
`if result.success:` 里;或者只**追加**、不覆盖。写完先跑一次**注定失败**的输入,看对外那两个字段是否自洽。
- 首次:2026-10-05|复发:0
### 23. 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住
- 现象:多重积分里体积写成 `wights = lows - highs`(名字打错 + 减反)。奇数维(1D、3D)估计值全为负、
而 **2D 看起来完全正常**(`∫∫ x·y` 得 +0.2499,正确值 0.25)—— 两个负号相乘抵消,把错误藏住了。
- 为什么会错:① "上界减下界"被顺手写成了"参数顺序相减",而变量名 `wights` 又不像 `lows` / `highs` 那样自带方向提示;
② 验算时只挑了一个二维例子(乘法的对称性恰好掩盖了符号),**维度是奇是偶没进脑子**。
同源教训见第 19 条(只想着主路径那种值)。
- 以后怎么防:长度/宽度/体积一律写 `high - low`,并在心里念一遍"大的减小的";
验算**至少各挑一个奇数维和一个偶数维**(1D + 3D 都能一秒现形);
拿到 `mean` 之后先跟解析值比**符号**,再看数值大小。
- 首次:2026-10-05|复发:0
### 24. 默认参数路径从没被执行过 —— 默认采样器用了库不接受的参数
- 现象:`random_walk` 的默认步长采样器写 `rng.integers(0, 2, size=size, dtype=float)`。numpy 的 `Generator.integers`
**不收浮点 dtype**(`TypeError: Unsupported dtype dtype('float64') for integers`),于是 `random_walk(100, 5)`
这种最普通的调用 100% 返回 `success=False`、`data=None`;连 docstring 里那条 `>>> r.data.shape (5, 101)` 都跑不通。
- 为什么会错:写的时候验的是**自定义采样器**那条分支,默认分支(`step_sampler is None`)一次都没执行过;
更巧的是 `try/except Exception` 把它压成了一句 message,看起来像"输入不合法"而不是"代码写错了"。
与第 21 条同源:**错误现场被 `except` 藏起来**,静态检查(pyright)也看不见。
- 以后怎么防:新函数写完,**第一个跑的就是"什么参数都不填的默认调用"**,`success` 必须是 `True`;
再把 docstring 的 Examples 真执行一遍(它们是最便宜的一组冒烟测试)。
凡是用到"库函数 + 一串可选参数"的地方,别凭记忆写,跑一次看它收不收。
- 首次:2026-10-05|复发:0
### 25. 两个维度恰好相等时,转置的形状检查会静默通过
- 现象:`random_walk` 把 `size=(n_paths, n_steps)` 交给 `step_sampler`,但没校验它返回的形状。
实测一个返回**转置**形状 `(n_steps, n_paths)` 的采样器:`n_paths=6, n_steps=4` 时报错(好事),
而 `n_paths=n_steps=5` 时 `success=True`、`data` 形状 `(5, 6)` 也"对"—— 里面的路径却是转置过来的,**静默错**。
- 为什么会错:形状检查依赖"两个维度不相等"这个前提才生效;`cumsum(axis=1)` + 赋值广播在方阵上完全合法。
验算时只挑了一组 `n_paths == n_steps` 的参数,正好落在唯一发现不了的那个点上。
与第 23 条同一个思路的不同侧面:**对称/相等会把错误藏起来**。
- 以后怎么防:验算参数**把两个维度取成不同值**(`n_paths=3, n_steps=100` 这种),别用正方形;
对外部传进来的批量数组显式校验 `shape == (n, d)`,别指望后面的运算替你把关。
- 首次:2026-10-05|复发:0
### 26. `except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸
- 现象:`random_walk` 把 `call_params = {...}` 写在 `try:` 里面,但 `except` 分支要 `params=call_params`。
于是**每一个失败路径**都变成 `UnboundLocalError: cannot access local variable 'call_params' where it is not associated with a value`
—— 实测 5 种失败输入(形状不对 / 增量含 NaN / 采样器抛异常 / `start` 非数值 / 返回 1-D 数组)**5 个全中**,
本该返回 `success=False` 的 `Result`,变成了把异常直接抛给调用方。
- 为什么会错:写的时候脑子里只过"成功路径",`except` 是照上一版的结构留下来的;
**异常分支只有真抛异常才会执行**,正常跑一遍、ruff、pyright 全都看不见(当时两个都 0 error)。
与第 24 条(默认路径没跑过)是同一类:**分支覆盖靠"我以为",不靠执行**。
还有一层:`params` 里那几个值(`n_steps` / `n_paths` / `start` / `random_state`)在 `try` 之前就已经定了,
它们本来就**不属于**"可能失败的计算",放进去纯属位置错误。
- 以后怎么防:① 写 `except` 时,逐个变量问"它是在 `try` 里才被赋值的吗"——**失败的返回要在 `try` 之前准备好**;
② 每写一个 `try`,必须配一个**注定失败**的用例(`assert r.success is False`),专门喂 `except` 分支;
③ 包装层的失败结果也要带 `params`,出问题才追得回去。
- 首次:2026-10-05|复发:0
### 27. 入口按类型分成两条路,两条路的校验口径互相矛盾
- 现象:`markov_simulate` 的 `initial` 用 `isinstance(initial, int)` 分流。标量那条按**路径数**铺满
(`np.full(n_paths, initial)`),数组那条却按**状态数**校验(`len(initial) != s` 报错),
而真正写进去的是 `paths[:, 0] = current_states`,长度要等于**路径数**。于是"数组 + `n_paths != s`"
这条组合永远跑不通;`n_paths == s` 时又能跑通 —— **同一个入口,两个维度各管一半**。
实测 `initial=[0.5, 0.5], n_paths=5` 直接在 `try` 外抛 `ValueError`(本该 `success=False`)。
- 为什么会错:写校验时是"顺着刚写完的那一行"补的 —— 标量分支当年是为了铺满路径,数组分支是照着"转移矩阵的状态数"
顺手写的,两边各自看着都对。**没有回到参数本身的语义上问一句"它到底是按谁计数"**。
同族:`np.int64(0)` 掉进数组分支(`isinstance` 只写了 `int`,易错点 1)、`True` 被当状态 1 静默放行。
- 以后怎么防:① 一个参数有多条类型分支时,**把每条分支的"计数维度"写在一张纸上**(按路径数?按状态数?按列数?),
对不上就说明口径没定清,**先定语义再写代码**;② 分流用的 `isinstance` 必须把 `np.integer` / `bool` 一起想到(易错点 1);
③ 任何"写进结果数组"的赋值,问一遍"它的长度约束和上面校验用的是同一个下标维度吗"。
- 首次:2026-10-05|复发:0
### 28. 数值阈值参数的两个漏网:`bool` 和 NaN
- 现象:`markov_steady_state` 的 `tol` 只写了 `if not isinstance(tol, (int, float)) or tol <= 0`。
`tol=True` → `isinstance(True, int)` 为真、`True > 0` 也为真 → 阈值变成 `1`,
**第 1 次迭代就"收敛"**,返回 `[0.55, 0.45]` 这种假答案,而且 `success=True, converged=True`。
`tol=np.nan` → `nan <= 0` 为假 → 穿过校验 → 白跑满 `max_iter` 次迭代。
同一个函数里 `max_iter` 查了 `bool`、`tol` 没查 —— 说明是**逐条手动补的**,不是照着清单过的。
- 为什么会错:脑子里"阈值 = 一个正数",于是校验只写"是数 & 大于 0";而 `bool` 是 `int` 的子类、
NaN 与任何数比较都是 `False`,这两条都恰好从"是数 & 大于 0"里漏出去。**与易错点 1 同根**,
只是这次漏的是"比较"而不是"类型判断"。
- 同族(同一轮发现的):只校验"转移矩阵行和 = 1.0"就以为矩阵合法 —— `[[1.5, -0.5], [0, 1]]` 行和也是 1.0,
但含负元素,迭代时 `v` 指数发散、最后 `data=[nan, nan]` 却 `success=True`。
**"行和 = 1"不等于"是概率矩阵"**,还要 `arr >= 0`。另外这类溢出会抛 `RuntimeWarning`,
而本项目 `filterwarnings = ["error::RuntimeWarning"]` —— **测试会直接报错**,别指望测试能兜住静默错。
- 以后怎么防:① 校验数值阈值时,脑子里过一遍"三件套":**类型(含 `bool` / `np.integer` 例外)、
有限性(`np.isfinite`,挡 NaN / inf)、取值区间**;② 同一函数里同类参数(`max_iter` / `tol` / `n` / `tol`)
用**同一套模板**写,别一个查 `bool` 一个不查;③ 校验"概率/权重"这类输入时,除了和为 1,还要问一句"有没有可能是负数/NaN"。
- 首次:2026-10-09|复发:0
### 29. 同一个校验里的两把尺子:一个宽容、一个精确
- 现象:`markov_simulate` / `markov_steady_state` 校验转移矩阵时有两条:
`if not np.allclose(arr.sum(axis=1), 1.0)`(**宽容**,默认 `atol=1e-8`)和 `if np.any(arr < 0)`(**精确**)。
实测 `[[1.0, -1e-16], [0.5, 0.5]]`:行和都是 1.0,但左边那行有个 `-1e-16` 的归一化残渣 →
被判"含负元素"直接报错。**这个矩阵在数学上没问题,是校验太严**。
- 为什么会错:两条校验是分两次、照着不同念头写的 —— 行和那条想着"浮点比较不能写 `==`",
非负那条想着"概率怎么能为负"。各自都对,**放在一起就成了一严一宽**。建模时矩阵常常是
`df / df.sum()` 归一化出来的,这类 `-1e-17` 残渣非常常见,比赛现场会直接卡住。
- 同族(同一轮里一起犯的):① 边界比较与文档口径不一致 —— `mm1_metrics` 写 `service_rate < arrival_rate`,
而 docstring 与测试都写 `λ >= μ` 不稳定,于是 `λ == μ` 掉进正常计算分支、`rho = 1` →
`ZeroDivisionError`(结果碰巧对,message 却是"计算失败:ZeroDivisionError");
② 一批报错文案是复制粘贴的,**参数名忘了改** —— 同一轮里 `max_iter` 的文案写 "tol"、
`service_rate` 的两条文案也都写 "tol"。两条都是"看着像对的、跑起来也行、但口径/文案是错的"。
- 以后怎么防:① 同一个量的多条校验,**先把容差定下来再写**(要么都用 `np.allclose` / 小 `eps`,要么都精确),
写完回头对一眼"这几条的严格程度一致吗";② 阈值类比较(`<` / `<=`)**对着 docstring 读一遍**,
特别是 == 落在哪边;③ 复制粘贴报错文案时,逐个把参数名念出来。
- 首次:2026-10-09|复发:0
### 30. 改了口径(返回值 / 键名 / 分支),下游三处会同时过期
- 现象:`markov_simulate` 这一轮把 `metrics` 从 `std_terminal` 换成 `terminal_distribution`、
`mm1_metrics` 的不稳定分支从 `metrics={}` 变成 `metrics={"rho"}`、`arrival_rate=0` 从"报错"变成"合法"。
代码本身没问题,但**同一轮的测试 5 条全红**(都在断言旧键名 / 旧分支),docstring 的 `Returns` 也还在写 `std_terminal`。
- 为什么会错:改的时候注意力全在"新行为对不对",**看不见的契约**(测试断言、docstring、STATUS 的口径表)没人提醒你。
这三处过期不会报错,只会"文档说 A、代码做 B",过几天自己都分不清哪个是准的。
- 以后怎么防:① 动返回值 / 键名 / 分支前,先 `rg` 一下这个名字在全仓的出现位置(`rg "std_terminal" `),
把"要一起改的地方"列出来再动手;② 改完立刻跑 `pytest` —— 红的测试就是"过期契约"的清单;
③ docstring 的 `Returns` / `Raises` 属于**契约的一部分**,和代码同一次改完,别留到"下次顺手"。
- 首次:2026-10-10|复发:0
### 31. 公式里的"基准量"抄错了对象,而且完美拟合会把错掩盖掉(R² 的 SS_tot)
- 现象:`models/metrics.py` 写 `ss_tol = np.sum((yt - np.mean(yp)) ** 2)` —— 分母的基准该是 `y_true` 的均值,
写成 `y_pred` 的均值了。实测(`y_true=[1,2,3,4,5]`、`y_pred=[1.5,2.5,2.5,4.5,5.5]`):错版 **0.8803827751196172**,
正确版 **0.875** = `sklearn.metrics.r2_score`。**而 `r2_score(y, y)` 照样返回 1**,常量 y 那条照样报错 ——
两种最常见的测试输入都盖住了这个错。
- 为什么会错:R² 的分子用 `y_pred`、分母用 `y_true` 的均值,两个变量名只差一个字母(`yt` / `yp`),
是从同一个表达式复制过来的。这种错"看起来像对、跑起来不报错、只有对着数据才露馅"。
- 以后怎么防:① 写完公式**逐个念一遍每个量的"是谁的"**(这个均值是 `y_true` 的还是 `y_pred` 的?);
② 指标类函数至少测一条**非完美拟合**的数据(完美拟合 / 常量 / 全 0 都是"天然掩盖 bug"的输入);
③ 拿成熟库当**独立参照物**(`sklearn.metrics.r2_score`、`scipy.stats`),别自己算自己;
④ 变量名别用 `ss_tol`(无意义),写 `ss_tot`(total sum of squares)—— 名字说得清,错法就少一半。
- 首次:2026-10-10|复发:0
### 32. `np.where` 的掩码防不住零除(两条分支都会被求值)
- 现象:`smape` 写 `np.where(denom == 0, 0.0, 2 * |yt - yp| / denom)`。分母为 0 时**数值结果是对的**(记 0),
但 `RuntimeWarning: invalid value encountered in divide` 仍会漏出来;本项目 `pyproject.toml` 开了
`filterwarnings = ["error::RuntimeWarning"]`,于是任何"含 0 的 smape"测试**直接判失败**。
- 为什么会错:`np.where` 是"**先把两条分支都算好,再按掩码挑**",不是 `if / else` 的短路求值。
"结果对" 和 "没有副作用" 是两件事,告警就是那个副作用。
- 以后怎么防:① 除法一律写 `np.divide(a, b, out=..., where=掩码)`(或临时 `with np.errstate(invalid="ignore")`),
不要拿 `np.where` 当挡箭牌;② 写完拿最脏的输入跑一遍(全 0、单个点、常量列),**盯着 warning 看**;
③ 项目开了 warning-as-error 时,"不告警"就是契约的一部分,要有一条测试专门钉住它。
- 首次:2026-10-10|复发:0
### 33. 长报错文案拆行时少一个引号 / 多一个逗号(`ruff` 一秒能抓,别等运行时)
- 现象:两条都在同一轮里写出来 —— ① `raise TypeError(` 下面的长中文 f-string 拆成两行,第一行**忘写结尾引号**,
Python 直接 `SyntaxError: unterminated f-string literal`(`ruff` 报 `invalid-syntax`,`import` 也跑不了);
② `raise ValueError(
f"...",
"..."
)` —— 想拼接两段文案却写成**两个参数**,
message 就成了元组的字符串 `('...', '...')`,看着像"报错文案有点怪",很难一眼看出是参数个数的问题。
- 为什么会错:中文文案长,按习惯拆行;`+` / 隐式拼接 / 逗号在编辑器里长得差不多。
这类错**不是逻辑错**,是"手滑",但会在最不该花时间的地方浪费两分钟。
- 以后怎么防:① 写完多行字符串,**立刻**跑一次 `ruff check`(或 `python -c "import ..."`)—— 语法错它必抓;
② 相邻字符串字面量的隐式拼接**不要夹逗号**,要拼接就老老实实写 `+` 或一次写完;
③ `Raises` 里放的不是长句而是"参数名 + 实际值",本身就不用拆行。
- 首次:2026-10-12|复发:0
+18
View File
@@ -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`;这里放的是**建模思路**。
+53
View File
@@ -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 . # 本项目(可编辑安装)
+42
View File
@@ -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()
+63
View File
@@ -0,0 +1,63 @@
"""data + stats 最小用法:读 → 清 → 补 → 判离群 → 缩尾 → 描述。
每一步都对应 `tests/test_data.py` 里的一条用例;体量刻意压到最小。
`stats.describe` 收尾:行 = 指标、列 = 变量,可直接贴进论文。
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import numpy as np
import pandas as pd
import mcm_toolkit as mcm
def main() -> None:
mcm.set_seed(20260101)
# 1) 假装这是从 Excel 导出的赛题数据:带文本列、带缺失、带两个录入错误
df = pd.DataFrame(
{
"city": ["A", "B", "C", "D", "E", "F"],
"gdp": [1000.0, 1100.0, np.nan, 1300.0, 1400.0, 9999.0],
"pop": [10.0, 11.0, np.nan, np.nan, 20.0, 15.0],
"notes": ["ok", "ok", np.nan, "ok", np.nan, "异常"],
}
)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "demo.csv"
# 2) 写盘再读回:load_table 按扩展名分派,.csv 会自动探测编码
mcm.data.save_table(df, path)
loaded = mcm.data.load_table(path)
print(f"读回 {loaded.shape[0]} 行 × {loaded.shape[1]} 列")
# 3) 缺失太多的行先删:缺失比例 > 0.5 的走人(4 列,阈值 0.5 -> 至少要 2 个非空)
kept = mcm.data.clean_missing(loaded, axis=0, threshold=0.5)
print(f"按缺失比例删行:{loaded.shape[0]} -> {kept.shape[0]}")
# 4) 剩下的缺失做填补。mean 只作用于数值列,notes 列原样保留缺失
filled = mcm.data.impute_missing(kept, method="mean")
print("填补后每列缺失数:", filled.isna().sum().to_dict())
# 5) 找离群点:只标不删,删不删由人决定
mask = mcm.data.detect_outliers(filled, method="iqr", k=1.5)
print("检出离群点:")
print(mask)
print(f"共 {int(mask.sum().sum())} 个;notes 列恒 False(非数值列不参与统计)")
# 6) 不删也能用:缩尾把极端值压到分位点上,而不是丢掉整行
w = mcm.data.winsorize(filled, lower=0.01, upper=0.99)
print(f"gdp 最大值 {filled['gdp'].max():.1f} -> 缩尾后 {w['gdp'].max():.1f}")
# 7) 描述统计:行 = 指标、列 = 变量,可直接贴进论文
table = mcm.stats.describe(filled[["gdp", "pop"]])
print(table.round(2))
if __name__ == "__main__":
main()
+48
View File
@@ -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()
+45
View File
@@ -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()
+24
View File
@@ -0,0 +1,24 @@
# 例子
每个模块一个能直接跑的最小例子。
> **当前状态**:这些脚本是**用法规格**,函数实现填好之前跑不通
> (会抛 `NotImplementedError`)。实现顺序见 `docs/03-roadmap.md`。
>
> `00_quickstart.py` 与 `01_clean_and_describe.py` 用到的函数大多已实现,可以跑;
> 两者末尾各有 `TODO(L0)`,等 `stats` / `viz` 补齐。
| 文件 | 对应模块 | 覆盖的 L 档 |
|---|---|---|
| `00_quickstart.py` | 全流程 | L0 |
| `01_clean_and_describe.py` | `data` + `stats` | L0 |
| `02_regression_compare.py` | `models` | L1 |
| `03_monte_carlo.py` | `solve/simulate` | L0 |
| `04_decision_topsis.py` | `decision` | L1 |
| `05_sensitivity.py` | `solve/sensitivity` | L1 |
跑法:
```powershell
.\.venv\Scripts\python.exe examples\03_monte_carlo.py
```
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+25
View File
@@ -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. 论文里被评审/队友质疑的地方是什么?→ 对应的函数要补文档或补鲁棒性检验。
+71
View File
@@ -0,0 +1,71 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mcm-toolkit"
version = "0.0.1"
description = "MCM/ICM 竞赛层工具箱:高频算法自实现 + 成熟库薄封装 + 统一结果接口"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "孤竹居士" }]
keywords = ["MCM", "ICM", "mathematical-modeling", "competition"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Topic :: Scientific/Engineering :: Mathematics",
]
# 核心依赖只留 4 个:core / data / stats / viz 只需要这些。
# 其余全部走 optional-dependencies,保证"比赛现场装不上包也能跑一半"。
dependencies = [
"numpy>=1.24",
"scipy>=1.10",
"pandas>=2.0",
"matplotlib>=3.7",
]
[project.optional-dependencies]
ml = ["scikit-learn>=1.3", "statsmodels>=0.14"]
graph = ["networkx>=3.1"]
sensitivity = ["SALib>=1.4"]
symbolic = ["sympy>=1.12"]
moo = ["pymoo>=0.6"]
excel = ["openpyxl>=3.1"]
all = ["mcm-toolkit[ml,graph,sensitivity,symbolic,moo,excel]"]
dev = ["pytest>=7.4", "ruff>=0.4", "pyright>=1.1"]
[project.urls]
Homepage = "https://github.com/"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
[tool.ruff]
line-length = 100
target-version = "py310"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "NPY", "RET", "PTH"]
ignore = ["E501"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
filterwarnings = ["error::RuntimeWarning"]
[tool.pyright]
include = ["src", "tests"]
typeCheckingMode = "basic"
# 不写 venvPath/venv 的话,pyright 会去 PATH 上找 python;本机 PATH 上的
# python 是 agent-tools 的 shim(Codex 运行时,Python 3.14,没有 numpy/pandas),
# 于是全部报 "无法解析导入 numpy/pandas"。指到工作区的 .venv 才稳定。
venvPath = "."
venv = ".venv"
# 与两套环境一致(venv 3.13.9 / conda 3.13.15);不写会跟着 PATH 上那个 3.14 走。
pythonVersion = "3.13"
+103
View File
@@ -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
+136
View File
@@ -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"
+82
View File
@@ -0,0 +1,82 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Create .venv inside the repo and install mcm-toolkit in editable mode.
.DESCRIPTION
Uses --system-site-packages so the venv reuses the scientific stack already
installed in Anaconda base. That makes the whole step OFFLINE: no package is
downloaded, yet numpy / scipy / pandas / matplotlib / sklearn / statsmodels /
networkx / sympy are all importable.
The editable install uses --no-deps --no-build-isolation: dependencies are
already provided by base, and pip must not go online for a build backend.
NOTE: this file is intentionally pure ASCII and UTF-8 without BOM.
Windows PowerShell 5.1 parses .ps1 as ANSI when there is no BOM, so non-ASCII
characters (e.g. Chinese) turn into illegal bytes and cause syntax errors.
.PARAMETER Python
Interpreter used to create the venv. Default: D:\anaconda\python.exe
.PARAMETER Recreate
Delete and recreate .venv if it already exists.
.EXAMPLE
.\scripts\setup-venv.ps1
#>
[CmdletBinding()]
param(
[string]$Python = 'D:\anaconda\python.exe',
[switch]$Recreate
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
$venvDir = Join-Path $repoRoot '.venv'
if (-not (Test-Path -LiteralPath $Python)) {
$cmd = Get-Command python -ErrorAction SilentlyContinue
if (-not $cmd) { throw "Python interpreter not found: $Python" }
$Python = $cmd.Source
Write-Host "Using python from PATH: $Python" -ForegroundColor Yellow
}
if (Test-Path -LiteralPath $venvDir) {
if (-not $Recreate) {
Write-Host '.venv already exists. Use -Recreate to rebuild.' -ForegroundColor Yellow
} else {
Write-Host 'Removing existing .venv ...'
Remove-Item -LiteralPath $venvDir -Recurse -Force
}
}
if (-not (Test-Path -LiteralPath $venvDir)) {
Write-Host '[1/4] creating .venv (--system-site-packages, reuses Anaconda base) ...'
& $Python -m venv --system-site-packages --prompt 'mcm-toolkit' $venvDir
if ($LASTEXITCODE -ne 0) { throw 'venv creation failed' }
}
$venvPy = Join-Path $venvDir 'Scripts\python.exe'
$venvSitePackages = Join-Path $venvDir 'Lib\site-packages'
Write-Host '[2/4] linking base site-packages for static analyzers (.pth) ...'
# pyright / Pylance do NOT honour "include-system-site-packages" in pyvenv.cfg,
# but they DO read *.pth files found in site-packages. Without this file they see
# only .venv\Lib\site-packages (which holds just pip), so every "import numpy"
# in the editor is reported as unresolved even though the interpreter is right.
$baseSitePackages = (& $Python -c "import sysconfig; print(sysconfig.get_paths()['purelib'])").Trim()
if (-not (Test-Path -LiteralPath $baseSitePackages)) { throw "base site-packages not found: $baseSitePackages" }
Set-Content -LiteralPath (Join-Path $venvSitePackages '_mcm_base_site_packages.pth') -Value $baseSitePackages -Encoding ASCII
Write-Host " -> $baseSitePackages"
Write-Host '[3/4] editable install of mcm-toolkit (--no-deps --no-build-isolation) ...'
& $venvPy -m pip install -e $repoRoot --no-deps --no-build-isolation --disable-pip-version-check
if ($LASTEXITCODE -ne 0) { throw 'pip install -e failed' }
Write-Host '[4/4] verifying ...'
& $venvPy -c "import mcm_toolkit, numpy, scipy, pandas, matplotlib; print('mcm_toolkit', mcm_toolkit.__version__); print('numpy', numpy.__version__)"
Write-Host ''
Write-Host 'Done. Activate with:' -ForegroundColor Green
Write-Host ' .\.venv\Scripts\Activate.ps1'
+54
View File
@@ -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",
]
+3
View File
@@ -0,0 +1,3 @@
"""版本号唯一来源。"""
__version__ = "0.0.1"
+25
View File
@@ -0,0 +1,25 @@
"""core —— 基础设施:结果对象、随机数、参数校验与入口转换。
其他所有包都依赖本包;本包**不**依赖任何其他子包。
"""
from .random import get_rng, set_seed
from .result import Result
from .validate import (
as_array,
check_array,
check_probability,
check_same_length,
check_square_matrix,
)
__all__ = [
"Result",
"as_array",
"get_rng",
"set_seed",
"check_array",
"check_probability",
"check_same_length",
"check_square_matrix",
]
+88
View File
@@ -0,0 +1,88 @@
"""随机数约定。
规则(见 docs/02-architecture.md §4.2):
* 所有含随机的公开函数签名都带 `random_state=None`,内部第一行取 rng。
* 禁止使用 `np.random.*` 旧式全局接口,也禁止裸用标准库 `random`。
(唯一例外是 :func:`set_seed` 里的 `np.random.seed`,见该函数说明。)
* 论文附录必须写明 seed —— 提交要能复现。
"""
from __future__ import annotations
import numpy as np
__all__ = ["get_rng", "set_seed"]
# 模块级共享生成器:set_seed 换掉它,无参 get_rng 复用它。
_GLOBAL_RNG: np.random.Generator | None = None
def get_rng(
random_state: int | np.integer | np.random.Generator | None = None,
) -> np.random.Generator:
"""取一个随机数生成器。
Parameters
----------
random_state:
`int` / `np.integer` 时返回固定种子的独立生成器(可复现);
`None` 时返回共享的全局生成器(由 :func:`set_seed` 设定,**会被推进**);
已经是 :class:`np.random.Generator` 时原样返回(便于函数间串联)。
Raises
------
TypeError
传进来的既不是整数、也不是 Generator、也不是 None。
Notes
-----
无参调用返回的是**同一个**生成器对象,所以连续两次 `get_rng()` 取到的随机数
不同(流在推进)。这既保证"重跑一遍结果一样",又避免两个都省略 `random_state`
的函数抽到完全相同的数。
Examples
--------
>>> rng = get_rng(42)
>>> bool(rng.random() == get_rng(42).random())
True
"""
if isinstance(random_state, np.random.Generator):
return random_state
# bool 是 int 的子类,但 True 被当成种子 1 几乎总是笔误,宁可报错也不静默接受。
if isinstance(random_state, bool):
raise TypeError("random_state 不接受 bool:True 会被当成种子 1,通常是笔误")
# np.int64 这类 numpy 标量**不是** python int 的子类,必须一起判断;
# 只写 int 的话,传进来的 numpy 整数会静默掉进下面的全局分支,种子被无声忽略。
if isinstance(random_state, (int, np.integer)):
return np.random.default_rng(int(random_state))
if random_state is not None:
raise TypeError(
"random_state 只接受 int / np.integer / np.random.Generator / None,"
f"收到的是 {type(random_state).__name__}"
)
global _GLOBAL_RNG
if _GLOBAL_RNG is None:
_GLOBAL_RNG = np.random.default_rng()
return _GLOBAL_RNG
def set_seed(seed: int | np.integer) -> None:
"""设置全局随机种子。
除了换掉本包的共享生成器,还会一并设置 numpy 的**旧全局状态**:
sklearn / statsmodels 内部走 `check_random_state(None)` 时用的就是它,
只管自己的生成器的话,这些库照样不可复现。
Examples
--------
>>> set_seed(42)
"""
global _GLOBAL_RNG
_GLOBAL_RNG = np.random.default_rng(seed)
# 全项目唯一允许碰旧接口的地方:这里是"设种子",不是"取随机数"。
np.random.seed(seed) # noqa: NPY002
+141
View File
@@ -0,0 +1,141 @@
"""统一结果对象。
第一版只有**一个** :class:`Result`(原建议里的 5 个 Result 类属于过早分类)。
任何"拟合 / 求解 / 模拟 / 评价"函数都只返回它,不返回裸 tuple。
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
# 单个值在摘要里的最大字符数,超了就截断。
_MAX_TEXT = 60
@dataclass
class Result:
"""所有计算型函数的统一返回值。
Parameters
----------
name:
产生该结果的方法名,例如 `"monte_carlo"`。
params:
参数估计 / 决策变量取值 / 权重等"输入侧"的结论。
metrics:
评估指标,例如 `{"rmse": 0.31, "r2": 0.94}`。
data:
主要载荷:预测值、模拟轨迹、排序结果、收益数组等。
success:
是否正常完成。失败时仍返回 Result,但 `success=False` 且 `message` 说明原因。
message:
给人看的一句话说明,可直接抄进论文的"方法说明"。
两个出口::meth:`summary` 给论文用(Markdown 表格),`repr` 给调试用(紧凑单行)。
Examples
--------
>>> r = Result("demo", params={"alpha": 0.5}, metrics={"r2": 0.9})
>>> r.success
True
"""
name: str
params: dict[str, Any] | None = None
metrics: dict[str, Any] | None = None
data: Any = None
success: bool = True
message: str = ""
extras: dict[str, Any] = field(default_factory=dict)
def __repr__(self) -> str:
"""紧凑的单行摘要,方便在 notebook / 调试器里一眼确认结果。
`data` 只给类型和规模,**绝不**整个打印——模拟轨迹动辄十万个数,
repr 一旦刷屏,traceback 和调试器就都没法看了。
`__repr__` 还必须永不抛异常,否则报错信息本身会变成报错源。
"""
parts = [f"success={self.success}"]
if self.params:
parts.append(f"params={_fmt_mapping(self.params)}")
if self.metrics:
parts.append(f"metrics={_fmt_mapping(self.metrics)}")
if self.data is not None:
parts.append(f"data={_describe_data(self.data)}")
if self.message:
parts.append(f"message={self.message!r}")
return f"Result({self.name}, {', '.join(parts)})"
def summary(self) -> str:
"""返回可直接贴进论文的 Markdown 表格。
行顺序为 `success` → `params.*` → `metrics.*` → `extras.*`,
按字典插入顺序输出(比赛时按自己想要的阅读顺序建字典即可)。
`message` 非空时以引用行附在表后。
Examples
--------
>>> print(Result("demo", metrics={"r2": 0.9}).summary())
### demo
| 项 | 值 |
|---|---|
| success | True |
| metrics.r2 | 0.9 |
"""
rows: list[tuple[str, Any]] = [("success", self.success)]
for prefix, mapping in (
("params", self.params),
("metrics", self.metrics),
("extras", self.extras),
):
for key, value in (mapping or {}).items():
rows.append((f"{prefix}.{key}", value))
lines = [f"### {self.name}", "", "| 项 | 值 |", "|---|---|"]
lines += [f"| {key} | {_fmt_cell(value)} |" for key, value in rows]
if self.message:
lines += ["", f"> {self.message}"]
return "\n".join(lines)
def _fmt_scalar(value: Any) -> str:
"""把一个值格式化成短、稳、论文友好的字符串。
`bool` 必须在 `int` 之前判断——`True` 同时是 `int` 的实例,
顺序反了就会把 `True` 打印成 `1`。
"""
if isinstance(value, bool):
return str(value)
if isinstance(value, float):
# 6 位有效数字:够论文用,又不会出现 0.30000000000000004。
return f"{value:.6g}"
if isinstance(value, int):
return str(value)
# 其他类型先归一化空白(换行会把单行 repr 和 Markdown 表格都撑破),再截断。
return _truncate(" ".join(str(value).split()))
def _fmt_cell(value: Any) -> str:
"""Markdown 表格单元格:值里出现竖线会把表格顶散,必须转义。"""
return _fmt_scalar(value).replace("|", r"\|")
def _fmt_mapping(mapping: Mapping[str, Any]) -> str:
return "{" + ", ".join(f"{key}: {_fmt_scalar(value)}" for key, value in mapping.items()) + "}"
def _describe_data(data: Any) -> str:
"""只描述 `data` 的类型与规模,不打印内容。"""
shape = getattr(data, "shape", None)
if shape is not None: # numpy 数组 / pandas DataFrame、Series
return f"{type(data).__name__}({', '.join(str(d) for d in shape)})"
if isinstance(data, str | bytes | list | tuple | set | frozenset | dict):
return f"{type(data).__name__}({len(data)})"
return type(data).__name__
def _truncate(text: str, limit: int = _MAX_TEXT) -> str:
return text if len(text) <= limit else text[: limit - 1] + "…"
+168
View File
@@ -0,0 +1,168 @@
"""参数校验与入口转换。
目的不是"教用户用库",而是**把错误提前到调用点**:
比赛时最贵的是 debug 时间,一个 `ValueError: 数组长度不一致` 比
十层 traceback 里的 `IndexError` 值钱得多。
**所有收数值的函数都从 `as_array` 进**:它一次做完"类型白名单 + 转 ndarray + 告诉调用方原来是什么",
省掉每个函数各抄一遍 `isinstance(x, pd.DataFrame)` / `select_dtypes` / `np.asarray`。
"""
from __future__ import annotations
from typing import Any, Literal, cast
import numpy as np
import pandas as pd
__all__ = [
"as_array",
"check_array",
"check_probability",
"check_same_length",
"check_square_matrix",
]
ArrayKind = Literal["frame", "series", "array"]
_INPUT_TYPES = (pd.DataFrame, pd.Series, np.ndarray, list, tuple, int, float)
def as_array(
x: Any,
*,
numeric_only: bool = True,
dtype: Any = float,
ndim: int | None = None,
) -> tuple[np.ndarray, ArrayKind]:
"""统一入口:把入参收成 `ndarray`,并告诉调用方"原来是什么"。
Parameters
----------
x:
`pandas.DataFrame` / `pandas.Series` / `numpy.ndarray` / `list` / `tuple`。
其余类型一律 `TypeError` —— 注意 `str` **也是可迭代对象**,白名单必须显式挡住,
否则 `"abc"` 会被当成三个字符悄悄算下去。
numeric_only:
默认 `True`:`DataFrame` **只取数值列**,非数值列自动跳过(一列都没有则 `ValueError`)。
走"元素级、输入输出同形"的函数(`normalize` / `standardize` / `log_transform`)
必须写 `numeric_only=False`:跳过列会让返回的表**少列**,与输入对不上;
输出是"一张数值结果表"的函数(`zscore` / `confidence_interval` / `t_test`)用默认值。
对 `Series` / 数组 / 列表无意义,自动忽略。
dtype:
目标 dtype,默认 `float`;传 `None` 表示保持原 dtype。
ndim:
期望维度,默认 `None` 表示**保持原样、绝不摊平**。
`1` 会**摊平**成一维("一串样本"的函数如 `t_test` / `confidence_interval` 要显式写);
`2` 只**校验**不重塑 —— 一维数据到底是"1 个样本"还是"1 个特征"没有唯一答案,不能替你猜。
Returns
-------
(numpy.ndarray, ArrayKind)
第二个元素是 `"frame"` / `"series"` / `"array"`,表示 `x` 原来是哪一类。
还原**故意不代做**,调用方自己手写(同一个函数不同形,没法一刀切):
```python
arr, kind = as_array(x, numeric_only=False)
... # 只写算法
if kind == "frame":
return pd.DataFrame(result, index=x.index, columns=x.columns)
if kind == "series":
return pd.Series(result, index=x.index, name=x.name)
return result
```
`numeric_only=True` 时列名要用**同一份口径**:
`x.select_dtypes(include=[np.number]).columns`。
Raises
------
TypeError
`x` 不在白名单里,或内容转不成 `dtype`。
ValueError
`numeric_only=True` 但 `DataFrame` 里一列数值都没有;`ndim` 不是 `None` / `1` / `2`;
`ndim=2` 但 `x` 不是二维。
Notes
-----
**本函数不删缺失值。** `DataFrame.corr` 是成对删、`describe` 是整列统计、
`paired=True` 的 t 检验必须**成对**删 —— 一旦在这里写 `arr[~isnan(arr)]`,
行与行的对应关系就永久丢了。删缺失是**语义**,交给各函数自己决定。
Examples
--------
>>> arr, kind = as_array(pd.DataFrame({"a": [1, 2], "city": ["X", "Y"]}))
>>> arr.shape, kind
((2, 1), 'frame')
"""
if isinstance(x, pd.DataFrame):
kind: ArrayKind = "frame"
source: Any = x
if numeric_only:
numeric_cols = x.select_dtypes(include=[np.number]).columns
if len(numeric_cols) == 0:
raise ValueError(f"x 里一列数值都没有,只有 {list(x.columns)}")
# x[Index] 在 pandas 存根里是 DataFrame | Series 重载,按运行时真值收窄(坑 8)
source = cast(pd.DataFrame, x[numeric_cols])
elif isinstance(x, pd.Series):
kind = "series"
source = x
elif isinstance(x, _INPUT_TYPES):
kind = "array"
source = x
else:
raise TypeError(
f"x 必须是 DataFrame / Series / ndarray / list / tuple,你传入了 {type(x).__name__}"
)
try:
arr = np.asarray(source, dtype=dtype)
except (ValueError, TypeError) as e:
raise TypeError(f"x 的内容转不成 {dtype} 数组:{e}") from None
if ndim is not None:
if ndim not in (1, 2):
raise ValueError(f"ndim 只能是 None / 1 / 2,你传入了 {ndim}")
if ndim == 1:
arr = arr.ravel()
elif arr.ndim != 2:
raise ValueError(f"ndim=2 要求二维输入,但 x 是 {arr.ndim} 维")
return arr, kind
def check_array(x: Any, ndim: int | None = None, finite: bool = True) -> np.ndarray:
"""转成 ndarray 并做基本检查(维度、有限值)。
实现时应当复用 `as_array`(丢掉第二个返回值即可),不要重写一遍转换逻辑。
Examples
--------
>>> check_array([1, 2, 3]).shape
(3,)
"""
# TODO(L1)
raise NotImplementedError
def check_same_length(*arrays: Any) -> None:
"""检查多个数组长度一致,不一致时抛出带长度明细的 ValueError。
Examples
--------
>>> check_same_length([1, 2], [3, 4])
"""
# TODO(L1)
raise NotImplementedError
def check_probability(p: float) -> float:
"""检查 p 落在 [0, 1],返回 float。"""
# TODO(L1)
raise NotImplementedError
def check_square_matrix(a: Any) -> np.ndarray:
"""检查是方阵,返回 ndarray。AHP 判断矩阵、转移矩阵都要用。"""
# TODO(L1)
raise NotImplementedError
+26
View File
@@ -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",
]
+266
View File
@@ -0,0 +1,266 @@
"""缺失值、离群值、缩尾。
`detect_outliers` 属于 S 档:不只是"算出来",还要能解释给队友听
为什么要踢掉这几个点(论文里必须交代),所以配完整函数卡片
(docs/dev/function-card-template.md)。
"""
from __future__ import annotations
import math
from typing import Any, Literal, cast
import numpy as np
import pandas as pd
__all__ = ["clean_missing", "detect_outliers", "impute_missing", "winsorize"]
def clean_missing(data: Any, axis: int = 0, threshold: float | None = None) -> Any:
"""删除缺失过多的行/列。
Parameters
----------
data:
要清洗的表,必须是 `pandas.DataFrame`。
axis:
`0` = 按行删,`1` = 按列删。
threshold:
缺失比例**超过**该值就删,取值 0–1;`None` 表示只要有缺失就删。
Returns
-------
pandas.DataFrame
清洗后的新表(不改原表)。
Raises
------
TypeError
`data` 不是 `pandas.DataFrame`。
ValueError
`axis` 既不是 0 也不是 1。
Examples
--------
>>> clean_missing(df, threshold=0.5) # doctest: +SKIP
"""
if axis == 0:
target_axis = 'index'
elif axis == 1:
target_axis = 'columns'
else:
raise ValueError(f"axis参数只能是 0 或 1,你传入了{axis}")
if not isinstance(data,pd.DataFrame):
raise TypeError(f"data必须是pandas DataFrame,你传入了{type(data)}")
if threshold is not None and not 0 <= threshold <= 1:
raise ValueError(f"threshold必须在0和1之间,你传入了{threshold}")
if threshold is None:
return data.dropna(axis=target_axis,how='any')
total_len = data.shape[1 -axis]
min_non_na = math.ceil(total_len * (1 - threshold))
return data.dropna(axis=target_axis,thresh=min_non_na)
def impute_missing(data: Any, method: str = "mean", **kwargs: Any) -> Any:
"""缺失值填补。
Parameters
----------
data:
要填补的表,必须是 `pandas.DataFrame`。
method:
`"mean"` / `"median"` / `"mode"` / `"ffill"` / `"interpolate"`。
**kwargs:
原样转交给底层的 `fillna` / `ffill` / `interpolate`。
Returns
-------
pandas.DataFrame
填补后的表。
Raises
------
TypeError
`data` 不是 `pandas.DataFrame`。
ValueError
`method` 不在支持列表里(报错信息会列出全部支持的方法)。
Notes
-----
论文里必须说明用了哪种填补、为什么。填 `mean` 之前先想一下
"这个缺失是不是非随机缺失(MNAR)",否则结论可能被填补方式带偏。
"""
if not isinstance(data,pd.DataFrame):
raise TypeError(f"data必须是pandas DataFrame,你传入了{type(data)}")
supported_method = ['mean','median','mode','ffill','interpolate']
if method not in supported_method:
raise ValueError(f"不支持的填补方式:{method},仅支持:{supported_method}")
# 上面已校验 method 合法,所以每条 return 之后不必再写 elif;
# 最后一行无条件 return 兜住仅剩的 interpolate,函数就没有"隐式返回 None"的路径了。
if method == 'mean':
return data.fillna(data.mean(numeric_only=True),**kwargs)
if method == 'median':
return data.fillna(data.median(numeric_only=True),**kwargs)
if method == 'mode':
# 只有这一支要逐列赋值,所以在这里单独复制一份,别就地改调用方的表
# (其余四支走 fillna / ffill / interpolate,本来就返回新对象)。
result = data.copy()
mode_df = result.mode()
for col in result.columns:
if mode_df[col].empty:
continue
result[col] = result[col].fillna(mode_df[col].iloc[0])
return result
if method == 'ffill':
return data.ffill(**kwargs)
return data.interpolate(**kwargs)
def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwargs: Any) -> Any:
"""标出离群点位置(返回布尔掩码,不直接删)。
Parameters
----------
data:
`pandas.DataFrame` / `pandas.Series`,或 `list` / `tuple` / `numpy.ndarray`。
method:
`"iqr"`:Q1-1.5·IQR 之外(`k` 可调,默认 1.5);
`"zscore"`:|z| > threshold(默认 3.0)。
**kwargs:
给对应方法用的参数:`k` 只对 `"iqr"` 生效,`threshold` 只对 `"zscore"` 生效。
Returns
-------
与原数据同形的布尔 DataFrame/ndarray,`True` 表示该点是离群点。
Raises
------
TypeError
`data` 既不是 `pandas.DataFrame` / `pandas.Series`,也不是 `list` / `tuple` / `numpy.ndarray`。
ValueError
`method` 既不是 `"iqr"` 也不是 `"zscore"`。
Notes
-----
**只标不删**是刻意的:删点是有代价的判断,必须由人来做,
并且要在论文里写清楚删了几个、依据是什么。
非数值列**不参与统计**,掩码里恒为 `False`:均值和分位数对文本没有意义,
但"这一列没有离群点"比"整张表算不出来"有用得多。
Examples
--------
>>> mask = detect_outliers([1, 2, 3, 4, 100], method="iqr")
>>> bool(mask[-1])
True
"""
if not isinstance(data, (pd.DataFrame, pd.Series, list, tuple, np.ndarray)):
raise TypeError(f"data必须是pandas DataFrame/Series 或数组型,你传入了{type(data)}")
is_series = isinstance(data, pd.Series)
is_ld_array = isinstance(data, (list, tuple, np.ndarray)) and np.array(data).ndim == 1
if isinstance(data,pd.Series):
data = data.to_frame()
elif not isinstance(data,pd.DataFrame):
data = pd.DataFrame(data)
if method == 'iqr':
k = kwargs.get("k", 1.5)
elif method == 'zscore':
threshold = kwargs.get("threshold", 3.0)
else:
raise ValueError(f"不支持的方法:{method},仅支持'iqr'或'zscore'")
numeric_cols = data.select_dtypes(include=['number']).columns
mask = pd.DataFrame(False,index=data.index,columns=data.columns)
# 取列子表得到的一定是 DataFrame,但 pandas 的类型存根只声明了「Hashable -> Series」,
# pyright 于是把 numeric 当成 Series,再把 `.std()` 的返回当成标量、判 `.replace` 非法。
# 这里按运行时的真值显式收窄,让下面几行继续参与类型检查。
numeric = cast(pd.DataFrame, data[numeric_cols])
if method == 'iqr':
Q1 = numeric.quantile(0.25)
Q3 = numeric.quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - k * IQR
upper_bound = Q3 + k * IQR
mask[numeric_cols] = (numeric < lower_bound) | (numeric > upper_bound)
elif method == 'zscore':
mean = numeric.mean()
std = numeric.std()
std = std.replace(0, np.nan)
z_scores = (numeric - mean) / std
mask[numeric_cols] = z_scores.abs() > threshold
if is_series:
return mask.iloc[:,0]
if is_ld_array:
return mask.values.flatten()
return mask
def winsorize(data: Any, lower: float = 0.01, upper: float = 0.99) -> Any:
"""缩尾:把超出分位数的值压到分位点,而不是删除。
Parameters
----------
data:
`pandas.DataFrame` 或 `pandas.Series`。
lower:
下分位点,默认 0.01(1%)。
upper:
上分位点,默认 0.99(99%)。
Returns
-------
pandas.DataFrame | pandas.Series
与输入同类型,超出分位点的值被压到分位点上。
Raises
------
TypeError
`data` 既不是 `pandas.DataFrame` 也不是 `pandas.Series`。
ValueError
分位数不满足 `0 < lower < upper < 1`。
"""
if not isinstance(data, (pd.DataFrame, pd.Series)):
raise TypeError(f"data必须是pandas DataFrame 或 Series,你传入了{type(data)}")
if not (0 < lower < upper < 1):
raise ValueError(f"分位数必须满足0 < lower < upper < 1,你传入了lower={lower},upper={upper}")
if isinstance(data,pd.Series):
if not pd.api.types.is_numeric_dtype(data):
return data.copy()
lower_val = data.quantile(lower)
upper_val = data.quantile(upper)
return data.clip(lower=float(lower_val),upper=float(upper_val))
numeric_cols = data.select_dtypes(include=['number']).columns
if len(numeric_cols) == 0:
return data.copy()
lower_bounds = data[numeric_cols].quantile(lower)
upper_bounds = data[numeric_cols].quantile(upper)
result = data.copy()
result[numeric_cols] = result[numeric_cols].clip(
lower=lower_bounds,
upper=upper_bounds,
axis=1
)
return result
+114
View File
@@ -0,0 +1,114 @@
"""读写表格。
只保留两个函数:`load_table` / `save_table` 按扩展名分派。
原建议里的 `load_csv` `load_excel` `save_csv` `save_excel` 四个函数
只是在转发 pandas,合并后调用点更短。
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pandas as pd
__all__ = ["load_table", "save_table"]
def load_table(path: str | Path, **kwargs: Any) -> pd.DataFrame:
"""按扩展名自动选择读取方式,返回 DataFrame。
约定:
* `.csv` 默认做编码探测(utf-8 → utf-8-sig → gbk → gb18030),
国内赛题数据常见 GBK;
* `.xlsx` / `.xls` 需要 `openpyxl`(`pip install "mcm-toolkit[excel]"`);
* `na_values` 默认包含空串、`"NA"`、`"N/A"`、`"#N/A"`、`"null"`、`"-"`。
Parameters
----------
path:
文件路径。扩展名决定用哪个读取器,大小写不敏感。
**kwargs:
原样转交给 `pandas.read_csv` / `pandas.read_excel`。
`.csv` 传了 `encoding=` 就照办,不再探测;不传则按上面的顺序逐个试。
Raises
------
FileNotFoundError
文件不存在。`.csv` 的自动探测分支会把原始路径包进中文提示里。
ValueError
后缀不在 `.csv` / `.xlsx` / `.xls` 里;
`.csv` 在候选编码下全部解码失败;
或 pandas 自身的解析错误(`pandas.errors.ParserError` 也是 `ValueError` 的子类,
报错信息原样抛出——它比转述的更有用)。
ImportError
`.xlsx` / `.xls` 但缺可选依赖(`openpyxl`,读旧 `.xls` 另需 `xlrd`),
见 docs/02-architecture.md §4.6:可选依赖的报错必须能直接看懂。
Examples
--------
>>> df = load_table("data.csv") # doctest: +SKIP
>>> df.shape # doctest: +SKIP
"""
path = Path(path)
suffix = path.suffix.lower()
encodings = ['utf-8','utf-8-sig','gbk','gb18030']
kwargs.setdefault('na_values',['','NA','N/A','#N/A','null','-'])
if suffix == ".csv":
if 'encoding' in kwargs:
return pd.read_csv(path,**kwargs)
for enc in encodings:
try:
return pd.read_csv(path,encoding=enc,**kwargs)
except UnicodeDecodeError:
continue
except FileNotFoundError:
raise FileNotFoundError(f"在这里找不到文件:{path}") from None
raise ValueError(f"无法解码文件,目前支持解码方式:{encodings}")
if suffix in ['.xlsx','.xls']:
return pd.read_excel(path,**kwargs)
raise ValueError(f"不支持的文件后缀:{suffix}")
def save_table(df: pd.DataFrame, path: str | Path, index: bool = False, **kwargs: Any) -> Path:
"""保存 DataFrame,返回实际写入的路径。
Parameters
----------
df:
要写出的表。必须是 `pandas.DataFrame`——`Series` 也不行,先 `.to_frame()`。
path:
目标路径。扩展名决定用哪个写入器,大小写不敏感;父目录不存在时自动创建。
index:
是否把行索引一并写进文件,默认 `False`。
**kwargs:
原样转交给 `pandas.DataFrame.to_csv` / `to_excel`。
Raises
------
TypeError
`df` 不是 `pandas.DataFrame`。
ValueError
后缀不在 `.csv` / `.xlsx` / `.xls` 里。
ImportError
写 `.xlsx` / `.xls` 但缺 `openpyxl`。
"""
path = Path(path)
suffix = path.suffix.lower()
if not isinstance(df,pd.DataFrame):
raise TypeError(f"df 必须是一个pandas DataFrame,但是你上传了{type(df)}")
# 校验必须排在副作用(建目录)之前:否则传个 .txt 会先建出一个空目录再抛错。
if suffix not in ('.csv','.xlsx','.xls'):
raise ValueError(f"不支持的文件后缀:{suffix}")
path.parent.mkdir(parents=True,exist_ok=True)
if suffix == '.csv':
df.to_csv(path,index=index,**kwargs)
else:
df.to_excel(path,index=index,**kwargs)
return path
+46
View File
@@ -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
+233
View File
@@ -0,0 +1,233 @@
"""变换与无量纲化。
**重要约定(见 docs/02-architecture.md §4.4)**:
本模块只做"无量纲化",**不改变指标方向**。
效益型 / 成本型的方向问题归 `decision` 包处理,用 `benefit_columns` 显式声明,
不允许默认假设"越大越好"——评价类题目翻车基本都翻在这里。
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
from ..core.validate import as_array
__all__ = ["log_transform", "normalize", "one_hot_encode", "standardize"]
def normalize(x: Any, method: str = "minmax", feature_range: tuple[float, float] = (0.0, 1.0)) -> Any:
"""无量纲化到给定区间。
Parameters
----------
x:
数值型标量 / list / tuple / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`;
多维输入一律**按列**(`axis=0`)处理。
method:
`"minmax"`:`(x - min) / (max - min)`,线性压缩到 `feature_range`;
`"vector"`:`x / ||x||`,保持"份额"含义(熵权法前处理常用);
`"max"`:`x / max(x)`。
feature_range:
仅 `method="minmax"` 时生效的目标区间,形如 `(low, high)`。
Returns
-------
numpy.ndarray / pandas.Series / pandas.DataFrame
与 `x` 同类型:DataFrame 进 DataFrame 出(列名、索引保持不变),
Series 进 Series 出,其余返回 `numpy.ndarray`。
Raises
------
ValueError
`method` 不在支持列表内,或 `feature_range[0] >= feature_range[1]`。
TypeError
`x` 类型不支持,或 `feature_range` 不是二元组。
Notes
-----
常量列(`max == min`)不产生 nan / inf,固定压到 `feature_range` 的下界。
minmax 对离群值极度敏感:一个 100 倍大的异常值会把其余所有点压成一条线。
先跑 `detect_outliers` 再决定。
Examples
--------
>>> normalize([1, 2, 3]).tolist()
[0.0, 0.5, 1.0]
"""
supported_method = ["minmax", "vector", "max"]
if method not in supported_method:
raise ValueError(f"不支持的方法:{method},仅支持{supported_method}")
if method == "minmax":
if not isinstance(feature_range, tuple) or len(feature_range) != 2:
raise TypeError(f"feature_range必须是形如(low, high)的二元组,你传入了{feature_range}")
if feature_range[0] >= feature_range[1]:
raise ValueError(f"feature_range的下界必须小于上界,你传入了{feature_range}")
# numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。
arr, kind = as_array(x, numeric_only=False)
if method == "minmax":
x_min = np.min(arr, axis=0)
x_max = np.max(arr, axis=0)
x_range = np.where(x_max - x_min == 0, 1.0, x_max - x_min)
scaled = (arr - x_min) / x_range
result = scaled * (feature_range[1] - feature_range[0]) + feature_range[0]
elif method == "vector":
norm = np.linalg.norm(arr, axis=0)
norm = np.where(norm == 0, 1.0, norm)
result = arr / norm
else:
# 上面已校验 method 合法,所以这里不必再写 elif,兜住仅剩的 "max"
x_max = np.max(arr, axis=0)
x_max = np.where(x_max == 0, 1.0, x_max)
result = arr / x_max
if kind == "frame":
return pd.DataFrame(result, index=x.index, columns=x.columns)
if kind == "series":
return pd.Series(result, index=x.index, name=x.name)
return result
def standardize(x: Any, ddof: int = 0) -> Any:
"""标准化 `(x - mean) / std`,输出均值 0、标准差 1。
Parameters
----------
x:
数值型标量 / list / tuple / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`;
多维输入一律**按列**(`axis=0`)处理。
ddof:
标准差的自由度修正,与 `numpy.std` 同名参数含义一致;`0` 为总体标准差。
Returns
-------
numpy.ndarray / pandas.Series / pandas.DataFrame
与 `x` 同类型,DataFrame 的列名与索引保持不变。
Raises
------
TypeError
`x` 类型不支持。
Notes
-----
常量列(`std == 0`)不产生 nan / inf,固定输出 0,与 `sklearn` 的
`StandardScaler` 对零方差列的处理一致。
"""
# numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。
arr, kind = as_array(x, numeric_only=False)
mean = np.mean(arr, axis=0)
std = np.std(arr, axis=0, ddof=ddof)
std = np.where(std == 0, 1.0, std)
result = (arr - mean) / std
if kind == "frame":
return pd.DataFrame(result, index=x.index, columns=x.columns)
if kind == "series":
return pd.Series(result, index=x.index, name=x.name)
return result
def log_transform(x: Any, offset: float | None = None) -> Any:
"""对数变换,用于压缩右偏分布。
Parameters
----------
x:
数值型标量 / list / tuple / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`。
offset:
平移量,算的是 `log(x + offset)`。
`None` 时自动取 `1 - min(x)`(当 `min(x) <= 0`),否则取 `0`,以保证定义域。
Returns
-------
numpy.ndarray / pandas.Series / pandas.DataFrame
与 `x` 同类型,DataFrame 的列名与索引保持不变。
Raises
------
TypeError
`x` 类型不支持。
ValueError
显式传入的 `offset` 会让 `x + offset <= 0`,即破坏 `log` 的定义域。
Examples
--------
>>> log_transform([1.0, 2.718281828459045]).round(3).tolist()
[0.0, 1.0]
"""
# numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。
arr, kind = as_array(x, numeric_only=False)
if offset is None:
min_val = np.min(arr)
offset = 1.0 - min_val if min_val <= 0 else 0.0
elif np.min(arr) + offset <= 0:
raise ValueError(
f"offset={offset} 会破坏 log 定义域: x + offset 的最小值为 {np.min(arr) + offset},必须大于 0"
)
result = np.log(arr + offset)
if kind == "frame":
return pd.DataFrame(result, index=x.index, columns=x.columns)
if kind == "series":
return pd.Series(result, index=x.index, name=x.name)
return result
def one_hot_encode(data: Any, columns: list[str] | None = None, drop_first: bool = False) -> Any:
"""类别变量独热编码。
Parameters
----------
data:
要编码的表,必须是 `pandas.DataFrame`。
columns:
要编码的列名列表;`None` 表示自动识别所有文本 / 类别列
(`object` / `category` / `str` / `string` 四种 dtype)。
drop_first:
是否丢掉每个类别变量的第一个水平,避免哑变量陷阱(等价 `pandas.get_dummies` 同名参数)。
Returns
-------
pandas.DataFrame
编码后的新表,**原表不改**;新哑变量列形如 `city_A`。
Raises
------
TypeError
`data` 不是 `pandas.DataFrame`。
ValueError
`columns` 里出现了原表中不存在的列名。
Notes
-----
这里**不能**把结果再按 `data.columns` 重新包一层 `DataFrame`:
哑变量列名(`city_A`)和原列名(`city`)对不上,那样会把编码结果整列丢掉。
"""
if not isinstance(data, pd.DataFrame):
raise TypeError(f"data必须是 pandas DataFrame, 你传入了{type(data)}")
if columns is not None:
missing_cols = [col for col in columns if col not in data.columns]
if missing_cols:
raise ValueError(f"指定的列不存在于数据中:{missing_cols}")
cols_to_encode = columns
else:
# "str" / "string" 必须显式写上:pandas 3 起字符串列的 dtype 是 "str",
# 只写 "object" 目前还能选中,但会发 Pandas4Warning,且官方声明未来移除该行为
# —— 到那天这里会**静默**一列都不编码,比报错难查得多。
cols_to_encode = data.select_dtypes(include=["object", "category", "str", "string"]).columns.tolist()
return pd.get_dummies(data, columns=cols_to_encode, drop_first=drop_first)
+26
View File
@@ -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",
]
+57
View File
@@ -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
+71
View File
@@ -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
+59
View File
@@ -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
+71
View File
@@ -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
+41
View File
@@ -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",
]
+31
View File
@@ -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),便于直接拼成对比表。"""
...
+406
View File
@@ -0,0 +1,406 @@
"""回归/分类指标 + 模型对比。
原建议把这批函数同时放在 `regression/`、`utils/metrics.py` 和第 26 节的
`analysis/` 三处,这里统一到一处。
注意:本模块被 `models` 与 `solve` 共用,属于"公共数学",但为了少建一个包,
放在 `models` 下;`solve` 单向依赖 `models` 是允许的。
本模块的指标函数**只返回 `float`,不返回 `Result`**(架构 §4.1 只覆盖拟合/求解/
模拟/评价);入口非法一律抛异常,见各函数的 `Raises`。唯一的例外是
`compare_models`:它按架构 §4.3 返回一张 `DataFrame` 对比表。
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import numpy as np
import pandas as pd
from mcm_toolkit.core import as_array, get_rng
__all__ = [
"adjusted_r2",
"compare_models",
"mae",
"mape",
"r2_score",
"rmse",
"smape",
]
def verify_yt_yp(y_true: Any, y_pred: Any) -> tuple[np.ndarray, np.ndarray]:
"""收成同形的一维 `ndarray`,并拦掉形状 / 空 / NaN / Inf / 样本不足五类非法输入。
指标函数的第一道关:先在这里把非法输入拦掉,函数体里就只剩公式。
刻意**不删缺失值**(与 `as_array` 同一口径):删了会悄悄改变样本量,
指标就不可比了,所以含 `NaN` / `Inf` 直接报错(决策 48)。
**至少 2 个样本**:1 个点的误差指标没有统计意义(`r2_score` 更是直接无定义)。
Parameters
----------
y_true, y_pred:
`DataFrame` / `Series` / `ndarray` / `list` / `tuple`,按 `as_array` 的规则
收成一维浮点数组(`str` 会被白名单挡住并 `TypeError`)。
Returns
-------
(numpy.ndarray, numpy.ndarray)
校验通过后的 `(y_true, y_pred)`,两者形状相同。
Raises
------
ValueError
形状不一致;长度为 0 或 1(指标至少要 2 个样本);含 `NaN` 或 `Inf`。
"""
yt, _ = as_array(y_true, ndim=1)
yp, _ = as_array(y_pred, ndim=1)
if yt.shape != yp.shape:
raise ValueError(f"y_true 与 y_pred 的形状必须一致,你传入的是 {yt.shape} 和 {yp.shape}")
if len(yt) == 0:
raise ValueError("y_true 与 y_pred 都不能为空")
if np.isnan(yt).any() or np.isnan(yp).any():
raise ValueError("有空值(NaN),请清洗或补充后再计算")
if np.isinf(yt).any() or np.isinf(yp).any():
raise ValueError("有无限值(Inf),请检查数据来源,修复除以0或溢出问题")
if yt.ndim == 0 or yt.shape[0] <= 1:
raise ValueError(f"输入至少需要包含 2 个样本,当前长度为 {yt.shape[0] if yt.ndim > 0 else 1}")
return yt, yp
def rmse(y_true: Any, y_pred: Any) -> float:
"""均方根误差。与 y 同量纲、对大误差敏感,回归里最常用的第一指标。
Parameters
----------
y_true, y_pred:
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
Returns
-------
float
`sqrt(mean((y_true - y_pred) ** 2))`,越小越好,0 表示完美拟合。
Raises
------
ValueError
未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。
"""
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
return float(np.sqrt(np.mean((yt - yp) ** 2)))
def mae(y_true: Any, y_pred: Any) -> float:
"""平均绝对误差。对离群值比 RMSE 稳健,和 RMSE 一起看能判断有无离群点。
Parameters
----------
y_true, y_pred:
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
Returns
-------
float
`mean(|y_true - y_pred|)`,越小越好。
Raises
------
ValueError
未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。
"""
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
return float(np.mean(np.abs(yt - yp)))
def mape(y_true: Any, y_pred: Any) -> float:
"""平均绝对百分比误差。无量纲,可跨量纲比较;`y_true` 必须**全为正数**。
Parameters
----------
y_true, y_pred:
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
Returns
-------
float
`mean(|y_true - y_pred| / y_true)`,`0.1` 表示平均偏差 10%。
Raises
------
ValueError
未通过 :func:`verify_yt_yp`(形状 / 样本数 / `NaN` / `Inf`),
或 `y_true` 里有非正数(0 → 分母为 0;负数 → 百分比误差无意义;
这种数据请改用 :func:`smape`)。
"""
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
if np.any(yt <= 0):
raise ValueError("y_true 包含非正数(0或负数),MAPE 在数学上无定义,请清洗数据或改用 SMAPE。")
return float(np.mean(np.abs(yt - yp) / yt))
def smape(y_true: Any, y_pred: Any) -> float:
"""对称 MAPE。`y_true` / `y_pred` 含 0 或为 0 都安全,是 MAPE 的替代品。
Parameters
----------
y_true, y_pred:
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
Returns
-------
float
`mean(2 * |y_true - y_pred| / (|y_true| + |y_pred|))`。
分子分母同时为 0 的点**记 0**(该点无偏差可言),不计入分母。
Raises
------
ValueError
未通过 :func:`verify_yt_yp`:形状不一致、样本少于 2 个、含 `NaN` 或 `Inf`。
"""
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
denom = np.abs(yt) + np.abs(yp)
# 分母为 0 的点记 0。注意 np.where 的两条分支**都会被求值**,直接写除式会触发
# "invalid value encountered in divide" 的 RuntimeWarning,而本项目 pytest 开了
# filterwarnings=error::RuntimeWarning,那样测试会直接算失败;所以用 where= 掩码。
ratio = np.divide(2 * np.abs(yt - yp), denom, out=np.zeros_like(denom), where=denom != 0.0)
return float(np.mean(ratio))
def r2_score(y_true: Any, y_pred: Any) -> float:
"""决定系数 R²。加变量一定会升高,跨模型比较要看 :func:`adjusted_r2`。
Parameters
----------
y_true, y_pred:
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
Returns
-------
float
`1 - SS_res / SS_tot`,其中 `SS_res = sum((y_true - y_pred) ** 2)`、
`SS_tot = sum((y_true - mean(y_true)) ** 2)`,基准是 `y_true` 的均值。
完美拟合约等于 1,比"直接取均值"还差时为负。
Raises
------
ValueError
未通过 :func:`verify_yt_yp`(形状 / 样本数 / `NaN` / `Inf`),
或 `y_true` 是常量(`SS_tot = 0`,分母为 0)。
"""
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
ss_res = np.sum((yt - yp) ** 2)
ss_tot = np.sum((yt - np.mean(yt)) ** 2)
if ss_tot == 0:
raise ValueError("y_true 是常量,SS_tot = 0,无法计算 R²")
return float(1 - (ss_res / ss_tot))
def adjusted_r2(y_true: Any, y_pred: Any, n_features: int | np.integer) -> float:
"""调整 R²,按自由度惩罚变量个数;同一样本量下比 R² 公平。
Parameters
----------
y_true, y_pred:
同形的一维数据,校验规则见 :func:`verify_yt_yp`。
n_features:
**自变量个数,不含截距**(`y = 2x + 1` 只有 1 个自变量)。
必须为正整数;`bool` 会被挡掉(`True` 看着像 1,但不是个数)。
Returns
-------
float
`1 - (1 - R²) * (n - 1) / (n - n_features - 1)`,`n = len(y_true)`。
比 R² 更保守:`n_features` 越大惩罚越重,所以它才是跨模型比较该看的那个。
Raises
------
TypeError
`n_features` 不是整数(含 `bool`、`float`、`np.float64`)。
ValueError
`n_features <= 0`;`n <= n_features + 1`(自由度为 0,分母为 0);
或 `y_true` / `y_pred` 未通过 :func:`verify_yt_yp`(形状 / 样本数 / `NaN` / `Inf`)。
"""
yt, yp = verify_yt_yp(y_true=y_true, y_pred=y_pred)
if isinstance(n_features, bool) or not isinstance(n_features, (int, np.integer)):
raise TypeError(f"n_features 必须是正整数,你传入了 {type(n_features).__name__}")
if n_features <= 0:
raise ValueError(f"n_features 必须是正整数,你传入了 {n_features}")
r2 = r2_score(yt, yp)
n = len(yt)
if n - n_features - 1 <= 0:
raise ValueError(f"样本量太小(n={n},自变量 {n_features} 个),分母会变成 0 或负数")
return float(1 - ((1 - r2) * (n - 1) / (n - 1 - n_features)))
# 指标名 → 函数。**别名表**就在这里:`"r2"` 与 `"r2_score"` 是同一个函数的两个名字,
# 传哪个名字,输出表里的列就叫哪个名字(模型的 `score()` 习惯用 `"r2"`,函数本名是
# `r2_score`,两个都收,省得记)。要加指标就往这个 dict 里添一行。
#
# `adjusted_r2` **不在这里**:它还要 `n_features`(自变量个数),`compare_models` 不知道
# 每个模型实际用了几个自变量,硬塞进来只会算错。单个模型要调整 R² 就自己调它。
_METRIC_FUNCS: dict[str, Callable[[Any, Any], float]] = {
"rmse": rmse,
"mae": mae,
"mape": mape,
"smape": smape,
"r2": r2_score,
"r2_score": r2_score,
}
# "越大越好"的指标:排序方向要反过来。其余(误差类)一律越小越好。
_HIGHER_IS_BETTER = frozenset({"r2", "r2_score"})
def _resolve_metrics(metrics: Any) -> dict[str, Callable[[Any, Any], float]]:
"""把指标名列表翻成 `{列名: 函数}`,顺手拦掉不认识的名字和重复的别名。"""
if isinstance(metrics, str):
raise TypeError("metrics 是一**组**指标名,写成 metrics=('rmse', 'r2'),不要传单个字符串")
if not isinstance(metrics, (list, tuple)):
raise TypeError(f"metrics 必须是 tuple / list,你传入了 {type(metrics).__name__}")
resolved: dict[str, Callable[[Any, Any], float]] = {}
for name in metrics:
fn = _METRIC_FUNCS.get(name)
if fn is None:
raise ValueError(f"不认识的指标名 {name!r};可选:{sorted(_METRIC_FUNCS)}")
if fn in resolved.values():
raise ValueError(f"{name!r} 与前面某个指标是同一个函数(别名),只挑一个传")
resolved[name] = fn
if not resolved:
raise ValueError("metrics 至少要有一个指标")
return resolved
def compare_models(
models: dict[str, Any],
X: Any,
y: Any,
metrics: tuple[str, ...] = ("rmse", "mae", "r2"),
cv: int = 5,
random_state: int | None = None,
) -> pd.DataFrame:
"""把若干模型放在一起打分,返回对比表。
这是赛题里最省时间的函数之一。队友问"要不要上复杂模型"时,
跑一次这个就能用数字回答,不用争论半小时。
Parameters
----------
models:
`{"linear": m1, "poly3": m2, "random_forest": m3}`。
每个模型需满足 :class:`mcm_toolkit.models.base.Model` 协议(有 `fit` / `predict`)。
**传模型对象本身,不是拟合结果** —— 拟合由本函数自己做。
X:
二维设计矩阵 `(n_samples, n_features)`;一列的 `x` 要自己 `x.reshape(-1, 1)`。
y:
一维真值,与 `X` 行数相同。
metrics:
要打分的指标名。可用 `"rmse"` / `"mae"` / `"mape"` / `"smape"` / `"r2"` / `"r2_score"`;
**`"r2"` 与 `"r2_score"` 是同一个函数的别名**(传哪个、列名就叫哪个),两个一起传会报错。
表按**第一个指标**排序:误差类升序(越小越好),R² 降序。
cv:
交叉验证折数;`cv=0` 表示在全部数据上拟合后打分(只看拟合能力时用)。
`cv >= 2` 时报的是**折间均值**,每折至少 2 个样本(指标本身的入口要求)。
**时序数据不要用它** —— 随机划分会让未来信息漏进训练集(要用滚动窗口)。
random_state:
随机种子(`cv >= 2` 时决定折怎么分)。传整数可复现;`None` 时跟随
`core.set_seed` 的全局流。
Returns
-------
pandas.DataFrame:行 = 模型(`models` 的键),列 = 指标(`metrics` 里的名字)。
按第一个指标排好序,可直接 `save_table(tbl, "compare.tex", fmt="latex")` 贴进论文。
Raises
------
TypeError
`models` 不是 dict、`cv` 不是整数、`metrics` 传了单个字符串,
或某个模型没有 `fit` / `predict`。
ValueError
`models` 为空、指标名不认识、两个指标是同一个别名、
`X` / `y` 未通过 `as_array`、样本数不一致、`cv` 取值不可能分折。
Notes
-----
分折是**用 numpy 手写的 K 折交叉验证**(`get_rng(random_state).permutation` +
`np.array_split`),所以不依赖 sklearn —— 比赛现场没装 `ml` 那组 extras 也能跑。
指标自己的入口要求照样生效:`mape` 要求 `y_true` 全为正数,含 0 或负数的数据改用 `smape`。
Examples
--------
>>> tbl = compare_models({"linear": m1, "poly": m2}, X, y, cv=0) # doctest: +SKIP
>>> tbl.index[0] # doctest: +SKIP
'poly'
"""
if not isinstance(models, dict):
raise TypeError(f"models 必须是 {{名字: 模型}} 的字典,你传入了 {type(models).__name__}")
if not models:
raise ValueError("models 不能为空,至少给一个模型")
for name, model in models.items():
if not (hasattr(model, "fit") and hasattr(model, "predict")):
raise TypeError(
f"模型 {name!r} 不满足 Model 协议:需要 fit / predict 两个方法;"
f"你传入了 {type(model).__name__}(注意:传模型对象,别传拟合结果)"
)
resolved = _resolve_metrics(metrics)
X_arr, _ = as_array(X, ndim=2)
y_arr, _ = as_array(y, ndim=1)
n = y_arr.shape[0]
if X_arr.shape[0] != n:
raise ValueError(f"X 与 y 的样本数必须一致,你传入的是 {X_arr.shape[0]} 和 {n}")
if n < 2:
raise ValueError(f"样本太少:至少要 2 个,当前 n={n}")
if isinstance(cv, bool) or not isinstance(cv, (int, np.integer)):
raise TypeError(f"cv 必须是整数,你传入了 {type(cv).__name__}")
if cv < 0:
raise ValueError(f"cv 不能为负数:0 表示全量打分,你传入了 {cv}")
if cv == 1:
raise ValueError("cv=1 没有意义:要么 cv=0(全量拟合后打分),要么 cv>=2(分折)")
if cv > 0 and cv > n // 2:
raise ValueError(
f"cv={cv} 太大:n={n}、每折至少要 2 个样本,最多只能分 {n // 2} 折;"
"样本太少的话改用 cv=0(全量拟合后打分)"
)
if cv == 0:
folds = [(np.arange(n), np.arange(n))]
else:
index = get_rng(random_state).permutation(n)
blocks = np.array_split(index, cv)
folds = [
(np.concatenate([blocks[j] for j in range(cv) if j != k]), blocks[k])
for k in range(cv)
]
scores: dict[str, dict[str, float]] = {}
for name, model in models.items():
per_fold: dict[str, list[float]] = {column: [] for column in resolved}
for train_idx, test_idx in folds:
model.fit(X_arr[train_idx], y_arr[train_idx])
y_pred = np.asarray(model.predict(X_arr[test_idx]))
for column, fn in resolved.items():
per_fold[column].append(fn(y_arr[test_idx], y_pred))
scores[name] = {column: float(np.mean(vals)) for column, vals in per_fold.items()}
table = pd.DataFrame.from_dict(scores, orient="index")
first = next(iter(resolved))
return table.sort_values(first, ascending=first not in _HIGHER_IS_BETTER)
+88
View File
@@ -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
+70
View File
@@ -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
+67
View File
@@ -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
+58
View File
@@ -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",
]
+79
View File
@@ -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
+86
View File
@@ -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
+234
View File
@@ -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
+214
View File
@@ -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
+800
View File
@@ -0,0 +1,800 @@
"""随机模拟:Monte Carlo / 马尔可夫 / 随机游走 / 排队。
★ 本模块的 `monte_carlo` 与 `monte_carlo_integral` 是 S 档,建议自己实现。
原建议里的 `brownian_motion` `geometric_brownian_motion` 已删(niche),
`simulate_queue` 也已删(等于自己写一个离散事件仿真引擎,成本远高于收益)。
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import numpy as np
from scipy.stats import norm
from mcm_toolkit.core import Result, as_array, get_rng
__all__ = [
"markov_simulate",
"markov_steady_state",
"mm1_metrics",
"monte_carlo",
"monte_carlo_integral",
"monte_carlo_probability",
"random_walk",
]
def monte_carlo(
sampler: Callable[..., Any],
simulator: Callable[..., Any],
n: int | np.integer = 10000,
random_state: int | None = None,
**kwargs: Any,
) -> Any:
"""通用蒙特卡洛:采样 → 仿真 → 汇总。
Parameters
----------
sampler:
`f(rng, size, **kwargs) -> 一批参数`。**一次就要抽够 `n` 组**,
例如 `lambda rng, size: rng.normal(0, 1, size)`。
批量大小由本函数以 `size=n` 传进去:`sampler` 必须能接住 `size`
(显式声明,或带 `**kwargs`)。**别再往 `**kwargs` 里塞 `size`**,否则重复传参。
simulator:
`f(params) -> 一批结果`。收到 `sampler` 的那一整批参数,
返回长度同为 `n` 的数组(标量 / 长度对不上都算契约错误)。
**内部不要写 Python 循环**。
n:
模拟次数,正整数。与 `simulator` 返回的长度必须一致。
random_state:
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
**kwargs:
原样转交给 `sampler`,例如分布参数 `mu` / `sigma`(`size` 由本函数自己传,见上)。
会一并记进 `Result.params`,便于复现。
Returns
-------
`Result`:`data` 是 `n` 个结果组成的数组(一律转成 float,布尔结果按 0/1 统计),
`metrics={"mean", "std", "median", "p05", "p95", "ci_low", "ci_high"}`。
Raises
------
TypeError:
`sampler` / `simulator` 不可调用,`n` 不是整数,或 `random_state` 类型非法。
ValueError:
`n` 不是正整数。
Notes
-----
比赛里 Monte Carlo 的收敛速度是 `O(1/√n)` —— 想少一半误差就要 4 倍样本。
所以论文里要给"区间随 n 收敛"的图,证明 `n` 已经够大
(这也是 `viz.plot_convergence` 的典型用法)。
结果里只要出现 **NaN** 就判失败(`success=False`):MC 的 NaN 通常来自除零、
取负数的根号或模型发散,静默统计只会得到一列 nan 指标。
Examples
--------
>>> import numpy as np
>>> draw = lambda rng, size, **k: rng.normal(0, 1, size)
>>> r = monte_carlo(draw, lambda x: x ** 2, n=10000, random_state=0)
>>> bool(abs(r.metrics["mean"] - 1.0) < 0.1) # E[X²] = 1
True
"""
if not callable(sampler) or not callable(simulator):
raise TypeError("sampler 和 simulator 必须是可调用函数")
if isinstance(n, bool) or not isinstance(n, (int, np.integer)):
raise TypeError(f"n 必须是整数,你传入了 {type(n).__name__}")
if int(n) <= 0:
raise ValueError(f"n 必须是正整数,你传入了 {n}")
n = int(n)
rng = get_rng(random_state)
call_params = {"n": n, "random_state": random_state, **kwargs}
try:
params = sampler(rng, size=n, **kwargs)
data = np.asarray(simulator(params), dtype=float).ravel()
if np.isnan(data).any():
raise ValueError("模拟结果中包含 NaN,请检查 sampler 或 simulator 的传入参数")
if len(data) != n:
raise ValueError(f"simulator 返回的结果数量({len(data)})必须与模拟次数 n({n})一致")
metrics = {
"mean": float(np.mean(data)),
"std": float(np.std(data, ddof=0)),
"median": float(np.median(data)),
"p05": float(np.percentile(data, 5)),
"p95": float(np.percentile(data, 95)),
"ci_low": float(np.percentile(data, 2.5)),
"ci_high": float(np.percentile(data, 97.5)),
}
return Result(
name="monte_carlo",
params=call_params,
metrics=metrics,
data=data,
success=True,
message=f"蒙特卡洛模拟成功:已完成 {n} 次模拟",
)
except Exception as e:
return Result(
name="monte_carlo",
params=call_params,
metrics={},
data=None,
success=False,
message=f"蒙特卡洛模拟失败:{type(e).__name__} - {e}",
)
def monte_carlo_probability(
event: Callable[..., Any],
sampler: Callable[..., Any],
n: int | np.integer = 10000,
confidence: float = 0.95,
random_state: int | None = None,
) -> Any:
"""估计事件发生概率,并给出区间。
Parameters
----------
event:
`f(params) -> 一批 bool`。吃到 `sampler` 的**整批**参数,
返回长度为 `n` 的布尔数组(写成 `z > 0` 这种逐元素判断即可,不要写 Python 循环)。
sampler:
`f(rng, size, **kwargs) -> 一批参数`,契约同 `monte_carlo`:
批量大小由本函数以 `size=n` 传入,`sampler` 必须能接住 `size`。
n:
模拟次数,正整数。与 `event` 返回的长度必须一致。
confidence:
区间置信水平,必须落在开区间 `(0, 1)` 内。
random_state:
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
Returns
-------
`Result`:`data` 是 `n` 个 0/1 结果组成的数组,
`metrics={"probability", "ci_low", "ci_high", "std_error"}`。
区间是 Wilson score 区间(`p` 贴近 0 / 1 时比 Wald 稳),并夹在 `[0, 1]` 内(比例量纲,见决策 42)。
Raises
------
TypeError:
`n` 不是整数,或 `random_state` 类型非法。
ValueError:
`n` 不是正整数,或 `confidence` 不在 `(0, 1)` 内。
Notes
-----
概率估计的相对误差 ≈ `1/√(n·p)`。**小概率事件(p < 1e-3)用朴素 MC 是不可行的**,
需要重要性采样或方差缩减——遇到这类题要提前意识到,别硬跑。
本函数当前的口径:`probability <= 1e-3` 时**直接判 `success=False`**(只给提示,不给区间)。
Examples
--------
>>> import numpy as np
>>> draw = lambda rng, size, **k: rng.normal(0, 1, size)
>>> r = monte_carlo_probability(lambda z: z > 0, draw, n=2000, random_state=0)
>>> abs(r.metrics["probability"] - 0.5) < 0.1
True
"""
if not callable(sampler) or not callable(event):
raise TypeError("sampler 和 event 必须是可调用函数")
if isinstance(n, bool) or not isinstance(n, (int, np.integer)):
raise TypeError(f"n 必须是整数,你传入了 {type(n).__name__}")
if int(n) <= 0:
raise ValueError(f"n 必须是正整数,你传入了 {n}")
n = int(n)
if confidence <= 0.0 or confidence >= 1:
raise ValueError(f"confidence必须在0和1之间,而你输入了{confidence}")
rng = get_rng(random_state)
call_params = {"n": n, "random_state": random_state, "confidence": confidence}
try:
params = sampler(rng, size=n)
data = np.asarray(event(params), dtype=float).ravel()
if np.isnan(data).any():
raise ValueError("模拟结果中包含 NaN,请检查 event 或 sampler 的传入参数")
if len(data) != n:
raise ValueError(f"event 返回的结果数量({len(data)})必须与模拟次数 n({n})一致")
p_value = float(np.mean(data))
std_error = float(np.sqrt(p_value * (1.0 - p_value) / n))
z_score = norm.ppf(1 - (1 - confidence) / 2)
center = (p_value + z_score**2 / (2 * n)) / (1 + z_score ** 2 / n)
half_width = (z_score / (1 + z_score ** 2 / n)) * np.sqrt(p_value * (1 - p_value) / n + z_score ** 2 / (4 * n ** 2))
ci_low = max(0.0, center - half_width)
ci_high = min(1.0, center + half_width)
warning_message = ""
if p_value <= 1e-3 or p_value >= 1 - 1e-3:
warning_message = "小概率事件 P < 1e-3 或者高概率事件 P > 1 - 1e-3,不建议使用monte_carlo_probability"
metrics = {
"probability": p_value,
"ci_low": float(ci_low),
"ci_high": float(ci_high),
"std_error": std_error,
}
return Result(
name="monte_carlo_probability",
params=call_params,
metrics=metrics,
data=data,
success=True,
message=(
f"蒙特卡洛模拟成功:已完成 {n} 次模拟"
+ (f";{warning_message}" if warning_message else "")
),
)
except Exception as e:
return Result(
name="monte_carlo_probability",
params=call_params,
metrics={},
data=None,
success=False,
message=f"蒙特卡洛模拟失败:{type(e).__name__} - {e}",
)
def monte_carlo_integral(
func: Callable[..., Any],
bounds: Any,
n: int | np.integer = 10000,
random_state: int | None = None,
) -> Any:
"""用蒙特卡洛求多重积分。
Parameters
----------
func:
`f(X) -> 一批函数值`。`X` 是形状 `(n, d)` 的样本矩阵(一行一个样本),
返回长度 `n` 的数组(写成 `X[:, 0] ** 2` 这种逐行写法,不要写 Python 循环)。
bounds:
`[(lo, hi), ...]`(非空 list / tuple),维度即积分重数;要求每项都是二元组且 `lo < hi`。
n:
模拟次数,正整数。
random_state:
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
Returns
-------
`Result`:**`metrics["mean"]` 才是积分估计值** ≈ `∫f dV`;
`data` 是 `n` 个样本各自的贡献(`func(X_i) × 体积`),可拿去画随 `n` 的收敛图。
`params` 里额外记了 `bounds` 与 `dimension`。
Raises
------
ValueError:
`bounds` 不是非空 list / tuple、形状不是 `(d, 2)`(例如漏括号写成 `[0, 1]`),或某个 `lo >= hi`。
Notes
-----
高维积分是 MC 相对网格法的**唯一绝对优势**:网格法误差 `O(h^{-d})` 随维度爆炸,
MC 始终是 `O(1/√n)`。三维以上优先考虑 MC。
Examples
--------
>>> import numpy as np
>>> r = monte_carlo_integral(lambda X: X[:, 0] ** 2, bounds=[(0, 1)], n=200000, random_state=0)
>>> abs(r.metrics["mean"] - 1 / 3) < 0.01 # ∫₀¹ x² dx = 1/3
True
"""
if not isinstance(bounds, np.ndarray):
if not isinstance(bounds, (list, tuple)) or len(bounds) == 0:
raise ValueError("bounds 必须是非空的列表或元组")
bounds_arr = np.asarray(bounds)
else:
bounds_arr = bounds.copy()
if bounds_arr.ndim != 2 or bounds_arr.shape[1] != 2:
raise ValueError(f"bounds 必须形如 [(lo, hi), ...],当前形状为 {bounds_arr.shape},请检查是否漏了括号")
lows = bounds_arr[:, 0]
highs = bounds_arr[:, 1]
d = len(lows)
if np.any(lows >= highs):
raise ValueError("积分边界非法:下界必须严格小于上界")
widths = highs - lows
volume = np.prod(widths)
def sampler(rng, size=n):
return rng.uniform(low=lows, high=highs, size=(size, d))
def simulator(X):
vals = func(X)
if len(vals) != n:
raise ValueError(f"func 返回的结果数量({len(vals)})必须与模拟次数 n({n})一致")
return np.asarray(vals, dtype=float).ravel() * volume
result = monte_carlo(
sampler=sampler,
simulator=simulator,
n=n,
random_state=random_state,
)
result.name = "monte_carlo_integral"
result.params["bounds"] = bounds
result.params["dimension"] = d
if result.success:
result.message = f"蒙特卡洛多重积分成功:维度 {d},体积 {volume:.4f},模拟 {n} 次。"
else:
result.message = f"蒙特卡洛多重积分失败:维度 {d},体积 {volume:.4f},模拟 {n} 次。{result.message}"
return result
def random_walk(
n_steps: int | np.integer,
n_paths: int | np.integer = 1,
step_sampler: Callable[..., Any] | None = None,
start: float = 0.0,
random_state: int | None = None,
) -> Any:
"""随机游走轨迹:一次抽完整批增量,再 `cumsum` 成路径。
Parameters
----------
n_steps:
每条路径的步数,正整数。`data` 的列数是 `n_steps + 1`(多出的那一列是起点)。
n_paths:
路径条数,正整数,默认 1 条。
step_sampler:
`f(rng, size) -> 一批增量`。**`size` 是整数** `n_paths * n_steps`(与 `monte_carlo` 的 `sampler` 同一契约),
必须返回**一维**长度 `n_paths * n_steps` 的数组;本函数负责 `reshape(n_paths, n_steps)`(行主序)。
返回二维一律判失败 —— 否则"转置的采样器"在 `n_paths == n_steps` 时会静默给出转置结果。
默认 ±1 等概率(对称随机游走)。
start:
起点,写进每条路径的第 0 列。
random_state:
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
Returns
-------
`Result`:`data` 形状 `(n_paths, n_steps + 1)`,第 0 列恒为 `start`,之后是逐列累加出来的位置;
`metrics={"mean_terminal", "std_terminal"}`(终点的均值与标准差,`std` 用 `ddof=0`)。
Raises
------
TypeError:
`n_steps` / `n_paths` 不是整数。
`start` 不是数值。
ValueError:
`n_steps` / `n_paths` 不是正整数。
Notes
-----
随机游走是"平方根扩散"的原型:位置的标准差随步数按 `√t` 增长,所以典型图形是 `±√t` 的喇叭口。
本函数只负责 `cumsum`;**建模差异全在 `step_sampler` 里** —— 换成 `rng.normal(0, 1, size)` 就是高斯游走,
换成带正漂移的增量就是有趋势的游走。
`step_sampler` 返回的不是**一维**、长度不等于 `n_paths * n_steps`,或增量里出现 NaN,都判 `success=False`
(NaN 口径与 `monte_carlo` 一致);其余异常同样统一走失败分支。
步与路径的对应关系是**行主序**:一维数组的前 `n_steps` 个元素属于第 1 条路径。
Examples
--------
>>> import numpy as np
>>> r = random_walk(n_steps=100, n_paths=5, random_state=0)
>>> r.data.shape
(5, 101)
>>> bool(np.all(np.abs(np.diff(r.data, axis=1)) == 1)) # 默认步长恒为 1
True
"""
if isinstance(n_steps, bool) or not isinstance(n_steps, (int, np.integer)):
raise TypeError(f"n_steps 必须是整数,你传入了 {type(n_steps).__name__}")
if n_steps <= 0:
raise ValueError(f"n_steps 必须是正整数,你传入了 {n_steps}")
if isinstance(n_paths, bool) or not isinstance(n_paths, (int, np.integer)):
raise TypeError(f"n_paths 必须是整数,你传入了 {type(n_paths).__name__}")
if n_paths <= 0:
raise ValueError(f"n_paths 必须是正整数,你传入了 {n_paths}")
if not isinstance(start, (int, float, np.number)):
raise TypeError(f"start 必须是数值,你传入了 {type(start).__name__}")
rng = get_rng(random_state)
if step_sampler is None:
def _default_step_sampler(rng, size):
return rng.choice([-1.0, 1.0], size=size)
step_sampler = _default_step_sampler
call_params = {
"n_steps": n_steps,
"n_paths": n_paths,
"start": start,
"random_state": random_state,
}
try:
steps = np.asarray(step_sampler(rng, size=n_paths * n_steps), dtype=float)
if steps.shape != (n_paths * n_steps,):
raise ValueError(
f"step_sampler 返回长度必须为 {n_paths * n_steps},实际得到了 {steps.shape}"
)
if np.isnan(steps).any():
raise ValueError("step_sampler 生成的增量数据中包含 NaN,请检查采样逻辑")
steps = steps.reshape(n_paths, n_steps) # 行主序:前 n_steps 个属于第 1 条路径
cumsum = np.cumsum(steps, axis=1)
paths = np.zeros((n_paths, n_steps + 1), dtype=float)
paths[:, 0] = start
paths[:, 1:] = start + cumsum
terminal_positions = paths[:, -1]
metrics = {
"mean_terminal": float(np.mean(terminal_positions)),
"std_terminal": float(np.std(terminal_positions))
}
return Result(
name="random_walk",
params=call_params,
metrics=metrics,
data=paths,
success=True,
message=f"随机游走模拟成功:生成了 {n_paths} 条路径,每条路径 {n_steps} 步。"
)
except Exception as e:
return Result(
name="random_walk",
params=call_params,
metrics={},
data=None,
success=False,
message=f"随机游走模拟失败:{type(e).__name__} - {str(e)}"
)
def markov_simulate(
transition: Any,
initial: Any,
steps: int | np.integer = 100,
n_paths: int | np.integer = 1,
random_state: int | None = None,
) -> Any:
"""马尔可夫链模拟:从 `initial` 出发,按 `transition` 逐行走 `steps` 步。
Parameters
----------
transition:
行随机矩阵(每行和为 1)的二维方阵,状态数就是它的阶数 `s`。
initial:
初始状态。**标量整数**时所有路径都从同一状态出发;
否则按一维数组处理,长度必须等于状态数 `s`,再写进每条路径的第 0 列
—— 只有 `n_paths == s` 时两者才对得上,见 Notes。
steps:
每条路径的步数,正整数。`data` 的列数是 `steps + 1`(多出的第 0 列是初始状态)。
n_paths:
路径条数,正整数,默认 1 条。
random_state:
随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。
Returns
-------
`Result`:`data` 是形状 `(n_paths, steps + 1)` 的整数状态序列,第 0 列是初始状态;
`metrics={"mean_terminal", "terminal_distribution"}` —— 前者是终点状态的均值,
后者是 `{状态: 出现频率}` 的 dict(按终点统计,只含**实际出现过**的状态,没出现的状态没有 key)。
Raises
------
TypeError:
`transition` 不是二维数组;`initial` 数组长度不等于状态数;
`steps` / `n_paths` 不是整数。
ValueError:
`transition` 不是方阵,或某一行和不等于 1.0;`steps` / `n_paths` 不是正整数。
Notes
-----
抽样用逆变换法:`cumsum` 得到累积分布,再拿 `[0, 1)` 的均匀数去"落格"。
一次抽完 `n_paths` 个状态,路径之间相互独立;步与步之间只能串行,因为下一步依赖当前状态。
`cum_probs[:, -1] = 1.0` 是为了压掉浮点累加误差,避免随机数落在 `[1 - 1e-16, 1)` 时索引越界。
终点分布随步数收敛到平稳分布:这既是本函数的自检手段,也是论文里值得写一句的性质。
遗留(决策 56):`initial` 靠 `isinstance(initial, (int, np.integer))` 区分"标量状态 / 状态数组",
`np.int64` 已收,但 `bool` 仍会被当成状态 1(`isinstance(True, int)` 为真);
且数组分支"按**状态数**校验长度"与真正写进去时"要按**路径数**"仍互相矛盾 —— 只有 `n_paths == s` 才跑得通。
状态越界(如 `initial=5`、`s=2`)也要等第一轮索引才炸。口径定了这段随之改写。
Examples
--------
>>> import numpy as np
>>> P = np.array([[0.9, 0.1], [0.2, 0.8]])
>>> r = markov_simulate(P, initial=0, steps=50, n_paths=4, random_state=0)
>>> r.data.shape
(4, 51)
>>> bool(np.all(r.data[:, 0] == 0))
True
"""
arr, _ = as_array(transition)
if arr.ndim != 2 or arr.shape[0] != arr.shape[1]:
raise ValueError(f"transition 必须是二维方阵,你传入的形状是 {arr.shape}")
if np.any(arr < -1e-12):
raise ValueError("transition 矩阵的元素必须是非负的")
if not np.allclose(arr.sum(axis=1), 1.0):
raise ValueError("transition 每一行的和必须是 1.0")
s = arr.shape[0]
if isinstance(n_paths, bool) or not isinstance(n_paths, (int, np.integer)):
raise TypeError(f"n_paths 必须是整数,你传入了 {type(n_paths).__name__}")
if n_paths <= 0:
raise ValueError(f"n_paths 必须是正整数,你传入了 {n_paths}")
if isinstance(steps, bool) or not isinstance(steps, (int, np.integer)):
raise TypeError(f"steps 必须是整数,你传入了 {type(steps).__name__}")
if steps <= 0:
raise ValueError(f"steps 必须是正整数,你传入了 {steps}")
rng = get_rng(random_state)
if isinstance(initial, (int,np.integer)):
current_states = np.full(n_paths, initial)
initial_param = int(initial)
else:
current_states, _ = as_array(initial, ndim=1)
if len(current_states) != s:
raise TypeError(f"initial 的长度必须是 {s},而你传入的是 {len(current_states)}")
initial_param = current_states.tolist()
call_params = {
"steps": steps,
"n_paths": n_paths,
"initial": initial_param,
"random_state": random_state
}
try:
paths = np.zeros((n_paths, steps + 1), dtype=int)
paths[:, 0] = current_states
for t in range(steps):
probs = arr[current_states]
cum_probs = np.cumsum(probs, axis=1)
cum_probs[:, -1] = 1.0
r = rng.random(n_paths)
next_states = (cum_probs < r[:, None]).sum(axis=1)
paths[:, t + 1] = next_states
current_states = next_states
terminal_positions = paths[:, -1]
unique, counts = np.unique(terminal_positions, return_counts=True)
terminal_dist = {int(state): float(count) / n_paths for state, count in zip(unique, counts, strict=True)}
metrics = {
"mean_terminal": float(np.mean(terminal_positions)),
"terminal_distribution": terminal_dist
}
return Result(
name="markov_simulate",
params=call_params,
metrics=metrics,
data=paths,
success=True,
message=f"马尔可夫链模拟成功,生成了 {n_paths} 条路径,每条路径 {steps} 步",
)
except Exception as e:
return Result(
name="markov_simulate",
params=call_params,
metrics={},
data=None,
success=False,
message=f"马尔可夫链模拟失败:{type(e).__name__} - {str(e)}",
)
def markov_steady_state(transition: Any, max_iter: int | np.integer = 10000, tol: float = 1e-10) -> Any:
"""迭代法求马尔可夫链的平稳分布(`v @ P` 反复推进,直到相邻两次几乎不变)。
Parameters
----------
transition:
行随机矩阵(每行和为 1)的二维方阵。
max_iter:
迭代上限,正整数,默认 10000。
tol:
收敛阈值,正数,默认 1e-10。判据是**相邻两次迭代的最大分量差**
`max|v_k+1 - v_k| < tol`,不是"到不动点的距离",见 Notes。
Returns
-------
`Result`:`data` 是一维平稳分布向量(和为 1);
`metrics={"converged", "iterations"}`(是否在 `max_iter` 内向容、实际迭代次数)。
Raises
------
TypeError:
`transition` 不是二维方阵;`max_iter` 不是整数;`tol` 不是数值。
ValueError:
`transition` 含负元素、或某一行和不等于 1.0;
`max_iter` 不是正整数;`tol` 不是正数(NaN 也算非法)。
Notes
-----
用迭代法而不是特征分解:迭代法能顺便告诉你"收敛快不快",
而收敛速度本身就是论文里值得写一句的性质。
初值是**均匀分布** `1/s`;**不可约 + 非周期**才有唯一平稳分布。
可约链(如单位阵 `eye(2)`)的平稳分布不唯一,迭代法给出的是"离初值最近的那个"
—— 均匀初值对双随机矩阵本身就是不动点,所以 `converged=True, iterations=1` 只说明"初值恰是不动点",
不代表这条链有唯一平稳分布;周期链从一般初值出发会一直振荡、`converged=False`。
`tol` 是**相邻差**口径,实际误差约被 `1 / (1 - |λ2|)` 放大:实测双状态慢混合链
`[[0.999, 0.001], [0.02, 0.98]]`(`|λ2| = 0.979`)用默认 `tol=1e-10` 停在第 867 次,
与特征向量相差 `4.6e-9`(约 50 倍)。慢混合链要更准,把 `tol` 压小即可。
Examples
--------
>>> import numpy as np
>>> P = np.array([[0.9, 0.1], [0.2, 0.8]])
>>> r = markov_steady_state(P)
>>> bool(np.allclose(r.data, [2 / 3, 1 / 3], atol=1e-6))
True
"""
arr, _ = as_array(transition)
if arr.ndim != 2 or arr.shape[0] != arr.shape[1]:
raise TypeError(f"transition 必须是二维方阵,你传入的形状是 {arr.shape}")
if np.any(arr < -1e-12):
raise ValueError("transition 矩阵的元素必须是非负的(概率不能为负)")
if not np.allclose(arr.sum(axis=1), 1.0):
raise ValueError("transition 每一行的和必须是 1.0")
if isinstance(max_iter, bool) or not isinstance(max_iter, (int, np.integer)):
raise TypeError(f"max_iter 必须是正整数,你传入了 {type(max_iter).__name__}")
if max_iter <= 0:
raise ValueError(f"max_iter 必须是正整数,你传入了 {max_iter}")
if not isinstance(tol, (int, float)) or isinstance(tol, bool):
raise TypeError(f"tol 必须是浮点数,你传入了 {type(tol).__name__}")
if tol <= 0 or np.isnan(tol):
raise ValueError(f"tol 必须是正数,你传入了 {tol}")
s = arr.shape[0]
v = np.full(s, 1.0 / s)
iterations = 0
converged = False
call_params = {
"transition": arr.tolist(),
"max_iter": max_iter,
"tol": tol,
}
try:
for _ in range(max_iter):
v_next = v @ arr
error = np.max(np.abs(v_next - v))
v = v_next
iterations += 1
if error < tol:
converged = True
break
metrics = {
"converged": converged,
"iterations": iterations
}
return Result(
name="markov_steady_state",
params=call_params,
metrics=metrics,
data=v,
success=converged,
message=f"平稳分布求解{'成功' if converged else '失败:未在最大迭代次数内收敛'},迭代 {iterations} 次。"
)
except Exception as e:
return Result(
name="markov_steady_state",
params=call_params,
metrics={},
data=None,
success=False,
message=f"平稳分布求解异常:{type(e).__name__} - {str(e)}"
)
def mm1_metrics(arrival_rate: float, service_rate: float) -> Any:
"""M/M/1 排队系统的闭式指标。
Parameters
----------
arrival_rate, service_rate:
到达率 λ 与服务率 μ,都收 `int` / `float` / numpy 标量。
`λ` 允许为 0(空系统:`L = Lq = Wq = 0`,`W` 取极限值 `1/μ`);
`λ >= μ` 时系统不稳定、队列长度无界 —— 这种情况返回 `success=False` 并说明原因,而不是返回 `inf`。
Returns
-------
`Result`:`metrics={"rho", "L", "Lq", "W", "Wq"}`
(系统利用率、系统内平均顾客数、队列长度、平均逗留时间、平均等待时间);
`data` 与 `metrics` 同值(闭式解没有样本数组),但**不是同一个对象**。
不稳定时 `success=False`、`metrics={{"rho"}}`(只报利用率)、`data=None`。
Raises
------
TypeError:
`arrival_rate` / `service_rate` 不是数值(`bool` 也判非法)。
ValueError:
`arrival_rate` 是负数;`service_rate` 不是正数。
Notes
-----
M/M/1 是唯一值得手写的排队模型:公式是闭式的,10 行搞定。
多服务员(M/M/c)和一般服务时间分布(M/G/1)在美赛出现频率极低,
真遇到时现场用仿真做,不做通用封装。
Examples
--------
>>> r = mm1_metrics(arrival_rate=2, service_rate=3)
>>> round(r.metrics["rho"], 4)
0.6667
"""
if not isinstance(arrival_rate, (int, float, np.number)) or isinstance(arrival_rate, bool):
raise TypeError(f"arrival_rate 必须是浮点数,你传入了 {type(arrival_rate).__name__}")
if arrival_rate < 0:
raise ValueError(f"arrival_rate 必须是非负数,你传入了 {arrival_rate}")
if not isinstance(service_rate, (int, float, np.number)) or isinstance(service_rate, bool):
raise TypeError(f"service_rate 必须是浮点数,你传入了 {type(service_rate).__name__}")
if service_rate <= 0:
raise ValueError(f"service_rate 必须是正数,你传入了 {service_rate}")
call_params = {"arrival_rate": float(arrival_rate), "service_rate": float(service_rate)}
if arrival_rate >= service_rate:
rho = arrival_rate / service_rate
return Result(
name="mm1_metrics",
params=call_params,
metrics={"rho": float(rho)},
data=None,
success=False,
message=f"系统不稳定:λ >= μ,队列无界。当前利用率 rho={rho:.4f}"
)
try:
rho = arrival_rate / service_rate
L = rho / (1.0 - rho) # 系统内平均顾客数
Lq = rho ** 2 / (1.0 - rho) # 队列中平均等待顾客数
W = 1.0 / (service_rate - arrival_rate) # 平均逗留时间
Wq = W - 1.0 / service_rate # 平均等待时间
metrics = {
"rho": float(rho),
"L": float(L),
"Lq": float(Lq),
"W": float(W),
"Wq": float(Wq)
}
return Result(
name="mm1_metrics",
params=call_params,
metrics=metrics,
data=metrics.copy(),
success=True,
message="M/M/1 排队系统指标计算成功。"
)
except Exception as e:
return Result(
name="mm1_metrics",
params=call_params,
metrics={},
data=None,
success=False,
message=f"计算失败:{type(e).__name__} - {str(e)}"
)
+36
View File
@@ -0,0 +1,36 @@
"""stats —— 描述统计、统计推断、bootstrap。
S 级模块。`bootstrap_ci` 建议自己实现一遍(S 档,配完整函数卡片):
它是赛题里"没有解析解时怎么给区间"的通用答案。
"""
from .bootstrap import bootstrap_ci, bootstrap_statistic
from .descriptive import coefficient_of_variation, describe, zscore
from .inference import (
anova,
chi_square_test,
confidence_interval,
correlation_matrix,
correlation_pvalues,
correlation_table,
fit_distribution,
goodness_of_fit,
t_test,
)
__all__ = [
"describe",
"zscore",
"coefficient_of_variation",
"correlation_matrix",
"correlation_pvalues",
"correlation_table",
"confidence_interval",
"t_test",
"anova",
"chi_square_test",
"fit_distribution",
"goodness_of_fit",
"bootstrap_statistic",
"bootstrap_ci",
]
+150
View File
@@ -0,0 +1,150 @@
"""Bootstrap 重采样。
★ S 档模块:建议自己完整实现一遍。
它是"没有解析解时怎么给区间"的通用答案,赛题里出现频率极高,
而且实现只有几十行——投入产出比最高的一个算法。
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import numpy as np
from ..core import Result, as_array, get_rng
__all__ = ["bootstrap_ci", "bootstrap_statistic"]
def bootstrap_statistic(
x: Any,
statistic: Callable[..., float],
n_resamples: int = 10000,
random_state: int | None = None,
) -> Any:
"""返回 `n_resamples` 个重采样统计量组成的数组。
Examples
--------
>>> import numpy as np
>>> vals = bootstrap_statistic([1, 2, 3, 4, 5], np.mean, n_resamples=200, random_state=0)
"""
# TODO(L1) ★ S 档
raise NotImplementedError
def bootstrap_ci(
x: Any,
statistic: Callable[..., float] | None = None,
confidence: float = 0.95,
n_resamples: int = 10000,
method: str = "percentile",
random_state: int | None = None,
) -> Any:
"""用重采样给出任意统计量的置信区间。
Parameters
----------
x:
样本:`pandas.DataFrame` / `pandas.Series` / `numpy.ndarray` / list / tuple。
走 `core.as_array(x, numeric_only=True, ndim=1)` —— DataFrame 只取数值列,再摊平成一串样本。
statistic:
任意把样本映射成标量的函数,默认 `np.mean`。
换成 `np.median` / `np.std` / 自定义的 `lambda s: s.max() - s.min()` 都能用,
这是 bootstrap 相对于 `mean ± z·s/√n` 的核心优势。
confidence:
置信水平,取 `(0, 1)` 开区间,默认 0.95。
n_resamples:
重采样次数,默认 10000。论文里必须写明。
method:
`"percentile"`:直接取经验分布的 α/2 与 1-α/2 分位(最常用,默认);
`"bca"`:偏差校正 + 加速,区间更准但慢,样本有偏时用 —— **尚未实现**,传了抛
`NotImplementedError`。
random_state:
随机种子。传 `None` 时复用 `core.get_rng` 的**模块级生成器且会推进**,
所以同一段脚本里连调两次结果不同;要完全可复现就显式给种子,或先 `core.set_seed`。
Returns
-------
`Result`:`params={"estimate", "low", "high", "confidence", "n_resamples"}`;
`metrics["statistic_name"]` 是统计量的名字(自定义 lambda 时是 `"custom"`)。
Raises
------
TypeError
`x` 不是受支持的容器,或内容转不成数值(由 `as_array` 抛)。
ValueError
`DataFrame` 里一列数值都没有;`method` 不在支持列表内。
NotImplementedError
`method="bca"`。
Notes
-----
* 重采样时必须**按块处理**,不要一次生成 `(10000, n)` 的整矩阵(n 大时会爆内存)。
* 样本量 n < 10 时 bootstrap 不可靠,先补数据。
* 比赛写论文时要注明:n_resamples、seed、用的是 percentile 还是 BCa(目前只实现了 percentile)。
* **缺失值目前不处理**:`x` 里的 NaN 会直接进统计量,`np.mean` 于是给出 nan 区间。
`confidence_interval` / `t_test` 都是先丢掉 NaN 的,两边口径尚未统一。
Examples
--------
>>> import numpy as np
>>> r = bootstrap_ci([1, 2, 3, 4, 5], np.median, random_state=0)
>>> r.params["low"] < 3 < r.params["high"]
True
"""
if statistic is None:
statistic = np.mean
rng = get_rng(random_state)
arr, _ = as_array(x=x, dtype=float, ndim=1)
original_estimate = float(statistic(arr))
n = len(arr)
batch_size = 1000
boot_stats = []
for i in range(0, n_resamples, batch_size):
current_batch = min(batch_size, n_resamples - i)
indices = rng.integers(0, n, size=(current_batch, n))
batch_samples = arr[indices]
if statistic in [np.mean, np.median, np.std, np.min, np.max]:
batch_stats = statistic(batch_samples, axis=1)
else:
batch_stats = np.array([statistic(s) for s in batch_samples])
boot_stats.extend(np.asarray(batch_stats).ravel().tolist())
boot_stats = np.array(boot_stats)
if method == 'percentile':
alpha = 1 - confidence
lower = np.percentile(boot_stats, alpha / 2 * 100)
upper = np.percentile(boot_stats, (1 - alpha / 2) * 100)
elif method == 'bca':
# TODO(L1): BCa = 偏差校正 + 加速,属于 S 档、留给用户自己补的算法。
raise NotImplementedError("BCa 还没实现,先用 method='percentile'")
else:
raise ValueError(f"不支持的方法:{method},仅支持'percentile'")
# lambda 也有 __name__(值恒为 "<lambda>"),所以只判断 hasattr 会让 "custom" 这支变成死代码。
name = getattr(statistic, "__name__", None)
statistic_name = "custom" if name in (None, "<lambda>") else str(name)
return Result(
name="bootstrap_ci",
params={
"estimate": original_estimate,
"low": float(lower),
"high": float(upper),
"confidence": confidence,
"n_resamples": n_resamples
},
metrics={
"statistic_name": statistic_name,
}
)
+209
View File
@@ -0,0 +1,209 @@
"""描述统计。
每个函数都只是"一句话数学含义 + 一句话什么时候用"(A 档),
不单独写文档卡片。
"""
from __future__ import annotations
from typing import Any, cast
import numpy as np
import pandas as pd
from ..core.validate import as_array
__all__ = ["coefficient_of_variation", "describe", "zscore"]
def describe(x: Any, weights: Any | None = None) -> pd.DataFrame:
"""一张表看完集中趋势、离散程度、偏度峰度、缺失情况。
`weights` 非空时给出加权均值与加权标准差(权重是"频数"或"重要性"时用)。
Parameters
----------
x:
`pandas.DataFrame` / `pandas.Series` / list / tuple / `numpy.ndarray`。
**只描述数值列**,非数值列自动跳过。
weights:
与 `x` 行数等长的**非负**权重,类型同 `x` 可接受的那几种;
`None` 表示不给加权行。
Returns
-------
pandas.DataFrame
行是指标,列是被描述的变量。指标依次为
`count` / `mean` / `std` / `min` / `25%` / `50%` / `75%` / `max` /
`median` / `missing` / `missing_ratio` / `skewness` / `kurtosis`;
给了 `weights` 时再追加 `weighted_mean` / `weighted_std`。
返回的是新表,原表不改。
Raises
------
TypeError
`x` 或 `weights` 的类型不支持。
ValueError
`x` 里没有数值列;或 `weights` 无法转成数值、不是一维、长度与行数不符、
含 NaN / inf、含负值、求和为 0。
Notes
-----
每个指标都**按列独立**计算;含缺失时 pandas 会自动跳过该单元格(`count` 因此
可能小于行数,缺失量看 `missing` / `missing_ratio`)。
加权标准差用总体口径(`ddof=0`)。含 NaN 的列加权后仍是 NaN ——
加权前请先用 `impute_missing` 补好。
Examples
--------
>>> describe(df).loc["mean"] # doctest: +SKIP
"""
allowed_types = (pd.DataFrame, pd.Series, list, tuple, np.ndarray)
if not isinstance(x, allowed_types):
raise TypeError(f"x 必须是{allowed_types},而你输入了{type(x)}")
if not isinstance(x, pd.DataFrame):
x = pd.DataFrame(x)
numeric_cols = x.select_dtypes(include=["number"]).columns
if len(numeric_cols) == 0:
raise ValueError("x 里没有数值列,无法描述")
# df[Index] 在 pandas 存根里是 DataFrame | Series 重载,按运行时真值收窄(坑 8)
df_num = cast(pd.DataFrame, x[numeric_cols])
# 这里**不要转置**。describe() 出来就是"行 = 指标、列 = 变量",正好让下面
# `.loc[指标名] = <按变量索引的 Series>` 能按列名对齐;一旦转置,Series 的索引
# 与表的列名不相交,pandas 会不报错地**把整行填成 NaN**(旧的静默 bug)。
table = df_num.describe()
table.loc["median"] = df_num.median()
table.loc["missing"] = df_num.isna().sum()
table.loc["missing_ratio"] = df_num.isna().mean()
table.loc["skewness"] = df_num.skew()
table.loc["kurtosis"] = df_num.kurt()
if weights is not None:
allowed_weight_types = (list, tuple, np.ndarray, pd.Series)
if not isinstance(weights, allowed_weight_types):
raise TypeError(f"weights 必须是{allowed_weight_types},而你输入了{type(weights)}")
try:
w_arr = np.asarray(weights, dtype=float)
except (ValueError, TypeError) as e:
raise ValueError(f"weights 无法转成数值数组,请检查是否含非数值字符:{e}") from None
if w_arr.ndim != 1:
raise ValueError(f"weights 必须是一维数组,而你传入了 {w_arr.ndim} 维")
if len(w_arr) != len(df_num):
raise ValueError(f"权重 weights 的长度({len(w_arr)})必须与数据的行数({len(df_num)})一致")
if not np.isfinite(w_arr).all():
raise ValueError("weights 含 NaN 或 inf,无法加权")
if (w_arr < 0).any():
raise ValueError("weights 不能含负值")
if w_arr.sum() == 0:
raise ValueError("weights 之和为 0,无法加权")
weighted_mean = np.average(df_num, axis=0, weights=w_arr)
weighted_std = np.sqrt(np.average((df_num - weighted_mean) ** 2, axis=0, weights=w_arr))
table.loc["weighted_mean"] = weighted_mean
table.loc["weighted_std"] = weighted_std
return table
def zscore(x: Any, ddof: int = 0) -> Any:
"""标准化到 z 分数,顺带回答了"这个值离均值几个标准差"。
Parameters
----------
x:
`pandas.DataFrame` / `pandas.Series` / list / tuple / `numpy.ndarray`。
DataFrame **只取数值列**,非数值列不会出现在结果里。
ddof:
标准差的自由度修正,与 `numpy.std` 同名参数含义一致;`0` 为总体标准差。
Returns
-------
numpy.ndarray / pandas.Series / pandas.DataFrame
与 `x` 同类型;DataFrame 只保留数值列,列名与索引不变。
常量列(`std == 0`)不产生 nan / inf,固定输出 0(决策 34)。
Raises
------
TypeError
`x` 类型不支持。
ValueError
DataFrame 里一列数值列都没有。
Notes
-----
与 `data.transform.standardize` 是同一个公式;差别只在 DataFrame 里遇到
非数值列时:这里**跳过**,`standardize` 会直接抛错。
"""
arr, kind = as_array(x, numeric_only=True)
mean = np.mean(arr, axis=0)
std = np.std(arr, axis=0, ddof=ddof)
std = np.where(std == 0, 1.0, std)
result = (arr - mean) / std
if kind == "frame":
# 列名口径与 as_array(numeric_only=True) 一致,都是只取数值列
cols = x.select_dtypes(include=[np.number]).columns
return pd.DataFrame(result, index=x.index, columns=cols)
if kind == "series":
return pd.Series(result, index=x.index, name=x.name)
return result
def coefficient_of_variation(x: Any) -> float:
"""变异系数 `std / |mean|` —— 跨量纲比较"谁更波动"时的唯一正确做法。
Parameters
----------
x:
一维数据:`pandas.Series` / list / tuple / `numpy.ndarray`,
或只有一列的 `pandas.DataFrame`。
Returns
-------
float
`std / |mean|`,标准差按总体口径(`ddof=0`)。恒非负。
Raises
------
TypeError
`x` 类型不支持。
ValueError
数据为空、不是一维(或 DataFrame 列数不为 1)、或均值为 0。
Notes
-----
分母取的是 `|mean|`:均值为负时 CV 仍是正数,不会因为符号反过来被误读成"波动更小"。
但 CV 的**可比性**仍然只在均值远离 0 的比率尺度数据上成立 —— 均值接近 0 时
CV 会被放大到任意大,那种数据请换用别的离散度指标。
"""
allowed_type = (list, tuple, np.ndarray, pd.Series)
if isinstance(x, pd.DataFrame):
if x.shape[1] != 1:
raise ValueError(f"变异系数只适用于一维数据,而你传入了 {x.shape[1]} 列的 DataFrame")
arr = np.asarray(x.iloc[:, 0], dtype=float)
elif isinstance(x, allowed_type):
arr = np.asarray(x, dtype=float)
if arr.ndim != 1:
raise ValueError(f"变异系数只适用于一维数据,而你传入了 {arr.ndim} 维数组")
else:
raise TypeError(f"x 必须是{allowed_type}或单列 pd.DataFrame,而你传入了{type(x)}")
if arr.size == 0:
raise ValueError("数据为空,无法计算变异系数")
mean = np.mean(arr)
std = np.std(arr, ddof=0)
if mean == 0:
raise ValueError("均值为 0,无法计算变异系数(分母为 0)")
return np.abs(float(std / mean))
+414
View File
@@ -0,0 +1,414 @@
"""统计推断:区间估计、假设检验、分布拟合。
原建议里的 `mean_test`/`proportion_test`/`distribution_pdf`/`distribution_cdf`/
`distribution_ppf`/`sample_distribution` 都是 scipy 的一行转发,已删。
需要 pdf/cdf/ppf 时,用 `fit_distribution(...).dist.pdf(...)` 即可。
"""
from __future__ import annotations
from typing import Any, Literal, cast
import numpy as np
import pandas as pd
import scipy.stats as stats
from ..core.validate import as_array
__all__ = [
"anova",
"chi_square_test",
"confidence_interval",
"correlation_matrix",
"correlation_pvalues",
"correlation_table",
"fit_distribution",
"goodness_of_fit",
"t_test",
]
def _pvalue(result: Any) -> float:
"""把 scipy 检验结果取成 p 值(float)。
scipy 的 `SignificanceResult` / `PearsonRResult` 是 `_make_tuple_bunch` 在**运行时**
拼出来的类,类型检查器看不到 `pvalue` 字段 —— 这里显式收成 `Any` 再取,
比给每一处调用挂 `# type: ignore` 干净,后面 `t_test` / `anova` 也能复用。
"""
return float(result.pvalue)
def _statistic(result: Any) -> float:
"""把 scipy 检验结果取成**统计量**(float)。
与 `_pvalue` 同一个原因:`TtestResult` 也是 `_make_tuple_bunch` 运行时拼的类,
存根里看不到 `statistic` 字段(见 `docs/04-environment.md` 坑 10)。
"""
return float(result.statistic)
_VALID_CORR_METHODS = ("pearson", "kendall", "spearman")
_VALID_CONFIDENCE_METHODS = ("normal", "t", "proportion")
def _check_corr_args(data: Any, method: str) -> pd.DataFrame:
"""三个 `correlation_*` 共用的入参守卫,返回收窄后的**数值子表**。"""
if not isinstance(data, pd.DataFrame):
raise TypeError(f"data必须是 pd.DataFrame,而你传的是{type(data)}")
if method not in _VALID_CORR_METHODS:
raise ValueError(f"不支持的方法:{method},仅支持{list(_VALID_CORR_METHODS)}")
numeric_cols = data.select_dtypes(include=[np.number]).columns
if len(numeric_cols) == 0:
raise ValueError("没有找到数值列")
# data[Index] 在 pandas 存根里是 DataFrame | Series 重载,按运行时真值收窄(坑 8)
return cast(pd.DataFrame, data[numeric_cols])
def correlation_matrix(
data: Any,
method: Literal["pearson", "kendall", "spearman"] = "pearson",
min_periods: int = 1,
) -> pd.DataFrame:
"""相关系数矩阵(**数值**,不带星标)。
Parameters
----------
data:
必须是 `pandas.DataFrame`。**只有数值列参与**,非数值列自动跳过。
method:
`"pearson"` / `"kendall"` / `"spearman"`。
min_periods:
每对变量至少要有这么多个**共同非缺失**的观测才给相关系数,透传给 `DataFrame.corr`。
(pandas 对每对变量都是**成对删缺失**,不是整行删。)
Returns
-------
pandas.DataFrame
对称矩阵,对角线恒为 `1.0`;观测不足的格子是 NaN。
Raises
------
TypeError
`data` 不是 `pandas.DataFrame`。
ValueError
`data` 里没有数值列,或 `method` 不在支持列表内。
Notes
-----
要"系数 + 显著性星标"的论文表格用 `correlation_table`;要 p 值矩阵用 `correlation_pvalues`。
之所以拆开:这张表是**数值**,能挑高相关对、喂聚类 / PCA、画热力图;星标字符串表这些都做不了。
Examples
--------
>>> correlation_matrix(df).round(3) # doctest: +SKIP
"""
df_num = _check_corr_args(data, method)
return df_num.corr(method=method, min_periods=min_periods)
def correlation_pvalues(
data: Any,
method: Literal["pearson", "kendall", "spearman"] = "pearson",
min_periods: int = 1,
) -> pd.DataFrame:
"""`correlation_matrix` 对应的 **p 值矩阵**(数值,不含星标)。
Parameters
----------
data / method / min_periods:
含义与 `correlation_matrix` 完全一致,两者一定同形。
Returns
-------
pandas.DataFrame
对称矩阵。**对角线是 NaN** —— "自己和自己相关"没有 p 值可言,
写成 `0.0` 会被下游读成"极显著"。
Raises
------
TypeError / ValueError:
同 `correlation_matrix`。
Notes
-----
每对变量都先**成对删缺失**再算,与 `DataFrame.corr` 的口径一致 ——
否则 r 和 p 会基于不同的样本量,对不上;成对有效观测少于 `min_periods` 时给 NaN。
只算上三角再镜像,成对变量的 p 天然对称,省掉一半重复计算。
"""
df_num = _check_corr_args(data, method)
corr = df_num.corr(method=method, min_periods=min_periods)
p_values = pd.DataFrame(np.nan, index=corr.index, columns=corr.columns, dtype=float)
for i in range(len(corr.columns)):
for j in range(i + 1, len(corr.columns)):
pair = df_num.iloc[:, [i, j]].dropna()
if len(pair) < min_periods:
continue
x = pair.iloc[:, 0]
y = pair.iloc[:, 1]
if method == "pearson":
p = _pvalue(stats.pearsonr(x, y))
elif method == "kendall":
p = _pvalue(stats.kendalltau(x, y))
else:
p = _pvalue(stats.spearmanr(x, y))
p_values.iloc[i, j] = p
p_values.iloc[j, i] = p
return p_values
def _star(value: float, p: float) -> str:
"""把系数与 p 值拼成 `"0.812**"`。p 为 NaN(对角线 / 观测不足)时不加星标。"""
text = f"{value:.3f}"
if p < 0.001:
return text + "***"
if p < 0.01:
return text + "**"
if p < 0.05:
return text + "*"
return text
def correlation_table(
data: Any,
method: Literal["pearson", "kendall", "spearman"] = "pearson",
min_periods: int = 1,
) -> pd.DataFrame:
"""可直接贴论文的相关性表:**系数 + 显著性星标**。
Parameters
----------
data / method / min_periods:
含义与 `correlation_matrix` 完全一致。
Returns
-------
pandas.DataFrame
每个格子是字符串,形如 `"0.812**"`。
星标:`***` p < 0.001、`**` p < 0.01、`*` p < 0.05,其余不带星。
**对角线只显示 `1.000` 不带星标** —— 自己和自己没有显著性可言。
Raises
------
TypeError / ValueError:
同 `correlation_matrix`。
Notes
-----
这张表**只能看**:格子里是字符串,做不了后续计算。
要数值请用 `correlation_matrix`(系数)与 `correlation_pvalues`(p 值)。
Examples
--------
>>> print(correlation_table(df)) # doctest: +SKIP
"""
corr = correlation_matrix(data, method=method, min_periods=min_periods)
p_values = correlation_pvalues(data, method=method, min_periods=min_periods)
table = corr.astype(str)
for i in range(len(corr)):
for j in range(len(corr.columns)):
table.iloc[i, j] = _star(corr.iloc[i, j], p_values.iloc[i, j])
return table
def confidence_interval(
x: Any,
confidence: float = 0.95,
method: str = "normal",
) -> tuple[float, float]:
"""均值或比例的置信区间。
Parameters
----------
x:
样本。`list` / `tuple` / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`;
传 DataFrame 时**所有数值列会被摊平成一个样本**,非数值列跳过。
confidence:
置信水平,取 `(0, 1)` 开区间,默认 0.95。
method:
`"normal"`:`mean ± z·s/√n`(n 大时用);
`"t"`:小样本用 t 分布(更保守,n < 30 建议用这个);
`"proportion"`:比例的标准误 `√(p(1-p)/n)`,结果会被夹到 `[0, 1]`。
Returns
-------
tuple[float, float]
`(下限, 上限)`,双侧区间。
Raises
------
TypeError
`x` 不是受支持的容器类型,或里面的元素转不成数值。
ValueError
`x` 里没有可用数值;`confidence` 不在 `(0, 1)` 内;
`method` 不在支持列表内;样本量不足 2(估不出标准差)。
Notes
-----
**只有 `proportion` 会把结果夹到 `[0, 1]`** —— 比例的区间不可能越界。
`normal` / `t` 是**均值**的区间,量纲上完全可以落在 `[0, 1]` 之外
(平均身高 170cm 的区间下界不可能是 0);夹一下只会给出**错的下界**。
Examples
--------
>>> lo, hi = confidence_interval([1, 2, 3, 4, 5], confidence=0.95)
"""
# 入口统一走 as_array(容器类型/数值转换的错误口径只有一处);样本是"一串数",显式摊平。
arr, _ = as_array(x, numeric_only=True, ndim=1)
if method not in _VALID_CONFIDENCE_METHODS:
raise ValueError(f"不支持的方法:{method},仅支持{list(_VALID_CONFIDENCE_METHODS)}")
if not 0 < confidence < 1:
raise ValueError(f"confidence 必须在 (0, 1) 开区间内,而你传入了{confidence}")
arr = arr[~np.isnan(arr)]
n = len(arr)
if n < 2:
raise ValueError(f"样本量至少要有 2 个才估得出标准差,而你只有{n}个")
if method == "proportion":
p = float(np.mean(arr))
se = float(np.sqrt(p * (1 - p) / n))
z = float(stats.norm.ppf(1 - (1 - confidence) / 2))
return (max(0.0, p - z * se), min(1.0, p + z * se))
mean = float(np.mean(arr))
se = float(np.std(arr, ddof=1)) / np.sqrt(n)
if method == "normal":
half = float(stats.norm.ppf(1 - (1 - confidence) / 2)) * se
else:
half = float(stats.t.ppf(1 - (1 - confidence) / 2, df=n - 1)) * se
return (mean - half, mean + half)
def t_test(a: Any, b: Any | None = None, value: float = 0.0, equal_var: bool = False, paired: bool = False, alpha: float = 0.05) -> Any:
"""t 检验:两组均值有没有统计学差异。`b=None` 时是单样本检验。
Parameters
----------
a:
第一组样本(`DataFrame` / `Series` / list / tuple / `ndarray`),缺失值自动剔除。
b:
第二组样本;`None` 表示单样本检验(对照 `value`)。
value:
单样本检验的原假设均值,`b is None` 时生效。
equal_var:
`True` 走学生 t(假设方差齐性),`False` 走 Welch(不假设,默认,更稳)。
paired:
配对样本检验;要求 `a` 与 `b` 等长。
alpha:
显著性水平,取 `(0, 1)` 开区间。
Returns
-------
dict
`{"statistic", "pvalue", "df", "significant", "conclusion"}`。
`conclusion` 是一句能直接抄进论文的中文结论。
Raises
------
TypeError
样本转不成数值数组。
ValueError
`alpha` 不在 `(0, 1)` 内;样本里没有数值列;样本量不足 2;
配对检验时两组长度不一致。
Notes
-----
传 `DataFrame` 时**所有数值列会被摊平成一个样本** —— 多列不同量纲时请先自己取列。
"""
if not (0 < alpha < 1):
raise ValueError(f"显著性水平 alpha 必须在 (0, 1) 之间,你传入了 {alpha}")
def _clean_data(data: Any) -> np.ndarray:
arr, _ = as_array(data, numeric_only=True, ndim=1)
return arr[~np.isnan(arr)]
a_arr = _clean_data(a)
if len(a_arr) < 2:
raise ValueError("样本 a 的数据量少于 2 个,无法进行 t 检验")
if b is None:
res = stats.ttest_1samp(a_arr, popmean=value)
df = len(a_arr) - 1
test_name = f"单样本 t 检验 (检验均值是否等于 {value})"
else:
b_arr = _clean_data(b)
if paired:
if len(a_arr) != len(b_arr):
raise ValueError("配对 t 检验要求两组数据长度一致")
res = stats.ttest_rel(a_arr, b_arr)
df = len(a_arr) - 1
test_name = "配对样本 t 检验"
else:
res = stats.ttest_ind(a_arr, b_arr, equal_var=equal_var)
if equal_var:
df = len(a_arr) + len(b_arr) - 2
test_name = "独立双样本 t 检验 (假设方差齐性)"
else:
v1, v2 = np.var(a_arr, ddof=1), np.var(b_arr, ddof=1)
n1, n2 = len(a_arr), len(b_arr)
df = (v1/n1 + v2/n2)**2 / ((v1/n1)**2/(n1-1) + (v2/n2)**2/(n2-1))
test_name = "Welch's 独立双样本 t 检验"
statistic = _statistic(res)
pvalue = _pvalue(res)
significant = bool(pvalue < alpha)
if significant:
conclusion = f"在 {alpha} 显著性水平下,{test_name} 结果显著 (p={pvalue:.4f} < {alpha}),拒绝原假设,认为两组数据存在统计学上的显著差异。"
else:
conclusion = f"在 {alpha} 显著性水平下,{test_name} 结果不显著 (p={pvalue:.4f} >= {alpha}),不能拒绝原假设,认为两组数据没有统计学上的显著差异。"
# 6. 返回字典
return {
"statistic": statistic,
"pvalue": pvalue,
"df": float(df),
"significant": significant,
"conclusion": conclusion
}
def anova(*groups: Any, alpha: float = 0.05) -> Any:
"""单因素方差分析:多组均值是否相同。
Returns
-------
dict:`{"F", "pvalue", "conclusion"}`。
"""
# TODO(L1)
raise NotImplementedError
def chi_square_test(observed: Any, expected: Any | None = None, alpha: float = 0.05) -> Any:
"""卡方检验:观察频数与期望频数是否一致(分类变量的独立性 / 拟合优度)。"""
# TODO(L1)
raise NotImplementedError
def fit_distribution(x: Any, candidates: tuple[str, ...] = ("norm", "lognorm", "gamma", "expon", "uniform")) -> Any:
"""对候选分布逐个拟合,按 AIC(或 KS 统计量)排序。
Returns
-------
`Result`:`data` 是排好序的候选表;`params["best"]` 是最优分布名,
`extras["dist"]` 是可直接调 `.pdf / .cdf / .ppf / `.rvs` 的 scipy 分布对象。
Notes
-----
这是"反正态题"的标准做法:先看图 → 候选分布拟合 → 比较 AIC → 做 KS 检验。
Examples
--------
>>> r = fit_distribution(data) # doctest: +SKIP
>>> r.params["best"] # doctest: +SKIP
'lognorm'
"""
# TODO(L1)
raise NotImplementedError
def goodness_of_fit(x: Any, dist: str = "norm", method: str = "ks") -> Any:
"""拟合优度检验:`"ks"`(连续)/ `"chi2"`(分箱)。"""
# TODO(L1)
raise NotImplementedError
+21
View File
@@ -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",
]
+170
View File
@@ -0,0 +1,170 @@
"""图表导出。
工具箱的最终消费者是**论文**,所以这一层不是"附赠功能",
而是原建议里最被低估的部分(它只给了一句 `save_figure`)。
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import numpy as np
from matplotlib.figure import Figure
__all__ = ["save_figure", "save_table"]
_ALLOWED_FORMATS = frozenset({"png", "pdf", "svg", "eps", "ps", "jpg", "jpeg", "tif", "tiff", "webp"})
def _normalize_formats(formats: Any) -> tuple[str, ...]:
"""校验并规范化输出格式;重复、空列表、单个字符串都在入口拦下。"""
if isinstance(formats, str):
raise TypeError("formats 必须是 tuple / list,不要传单个字符串(会把 'png' 拆成 p/n/g)")
if not isinstance(formats, (tuple, list)):
raise TypeError(f"formats 必须是 tuple / list,收到 {type(formats).__name__}")
if not formats:
raise ValueError("formats 不能为空")
normalized: list[str] = []
for fmt in formats:
if not isinstance(fmt, str):
raise TypeError(f"formats 里的每一项都必须是字符串,收到 {type(fmt).__name__}")
key = fmt.lower().lstrip(".")
if key not in _ALLOWED_FORMATS:
allowed = ", ".join(sorted(_ALLOWED_FORMATS))
raise ValueError(f"不支持的图片格式:{fmt!r};可选:{allowed}")
if key in normalized:
raise ValueError(f"formats 里有重复的格式:{key!r}")
normalized.append(key)
return tuple(normalized)
def save_figure(
fig: Any,
path: str | Path,
dpi: int | np.integer = 300,
formats: tuple[str, ...] | list[str] = ("png", "pdf"),
bbox_inches: str | None = "tight",
transparent: bool = False,
) -> list[Path]:
"""保存图,默认同时出 PNG(预览用)和 PDF(LaTeX 插图用)。
Parameters
----------
fig:
`matplotlib.figure.Figure`(`plt.subplots()` 的第一个返回值)。
path:
文件名**主干**。若带了 `png` / `pdf` 等图片后缀,会先去掉后缀,
再按 `formats` 逐个生成;父目录不存在时自动创建。
dpi:
输出分辨率,默认 300 —— 美赛送稿的 DPI 底线。
formats:
要写的格式,tuple / list,大小写不敏感,顺序决定返回顺序。
支持 png / pdf / svg / eps / ps / jpg / jpeg / tif / tiff / webp。
bbox_inches:
传给 `Figure.savefig`;默认 `"tight"` 去掉多余白边,`None` 用 matplotlib 默认值。
transparent:
是否透明背景。正文用图一般保持 `False`,叠加图例或封面图可以用 `True`。
Returns
-------
实际写出的文件路径列表,顺序与 `formats` 一致。
Raises
------
TypeError
`fig` 不是 `Figure`;`formats` 不是 tuple / list、含非字符串项;
`dpi` / `transparent` / `bbox_inches` 类型不对。
ValueError
`formats` 为空、含不支持的格式或重复格式;`dpi <= 0`;
`path` 为空或是一个目录。
Notes
-----
所有校验都在建目录、写文件**之前**完成,传错格式不会留下空目录。
建议配合项目的 figure 目录约定:`figures/fig{序号}_{短名}.pdf`,
图号与正文引用一一对应,最后核对时不容易错位。
Examples
--------
>>> fig, ax = plt.subplots() # doctest: +SKIP
>>> paths = save_figure(fig, "figures/fig1")
>>> [p.suffix for p in paths]
['.png', '.pdf']
"""
if not isinstance(fig, Figure):
raise TypeError(f"fig 必须是 matplotlib 的 Figure,收到 {type(fig).__name__}")
if isinstance(dpi, bool) or not isinstance(dpi, (int, np.integer)):
raise TypeError(f"dpi 必须是正整数,收到 {type(dpi).__name__}")
if dpi <= 0:
raise ValueError(f"dpi 必须大于 0,收到 {dpi}")
if not isinstance(transparent, bool):
raise TypeError(f"transparent 必须是 bool,收到 {type(transparent).__name__}")
if bbox_inches is not None:
if not isinstance(bbox_inches, str):
raise TypeError(f"bbox_inches 必须是字符串或 None,收到 {type(bbox_inches).__name__}")
if not bbox_inches.strip():
raise ValueError("bbox_inches 不能是空字符串")
if not isinstance(path, (str, Path)):
raise TypeError(f"path 必须是 str 或 Path,收到 {type(path).__name__}")
base = Path(path)
if not base.name:
raise ValueError(f"path 不能为空:{path!r}")
if base.is_dir():
raise ValueError(f"path 是一个目录,请给文件名主干:{base}")
if base.suffix and base.suffix.lower().lstrip(".") in _ALLOWED_FORMATS:
base = base.with_suffix("")
normalized = _normalize_formats(formats)
base.parent.mkdir(parents=True, exist_ok=True)
written: list[Path] = []
for fmt in normalized:
target = base.with_suffix(f".{fmt}")
fig.savefig(
target,
dpi=int(dpi),
bbox_inches=bbox_inches,
transparent=transparent,
format=fmt,
)
written.append(target)
return written
def save_table(
df: Any,
path: str | Path,
fmt: tuple[str, ...] = ("csv", "latex", "markdown"),
caption: str | None = None,
label: str | None = None,
float_format: str = "%.4f",
index: bool = True,
) -> list[Path]:
r"""导出结果表,默认同时出 CSV + LaTeX + Markdown。
这是**节省时间最多的一个函数**:`compare_models`、`parameter_sweep`、
`topsis` 的结果都能直接 `save_table(r.data, "tab3_model_compare")`,
然后把 LaTeX 片段粘进论文,不用手工敲表格。
Parameters
----------
caption / label:
写进 LaTeX 的 `\caption{}` 与 `\label{}`,
表号与正文 `\ref{}` 的对应关系就不会断。
Returns
-------
实际写出的文件路径列表。
Examples
--------
>>> paths = save_table(df, "tables/tab2", caption="模型对比") # doctest: +SKIP
"""
# TODO(L1)
raise NotImplementedError
+79
View File
@@ -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
+40
View File
@@ -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
View File
Whitespace-only changes.
+32
View File
@@ -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],
]
)
+67
View File
@@ -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)
+220
View File
@@ -0,0 +1,220 @@
"""core 包测试。
TODO(L0):TestValidate 的 check_* 实现后去掉 skip 标记(Result / Random / as_array 已完成)。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
class TestRandom:
def test_get_rng_is_reproducible(self):
from mcm_toolkit.core import get_rng
assert get_rng(42).random() == get_rng(42).random()
def test_get_rng_passes_through_generator(self):
from mcm_toolkit.core import get_rng
g = np.random.default_rng(0)
assert get_rng(g) is g
def test_get_rng_accepts_numpy_integer(self):
"""np.int64 不是 int 的子类,必须当成整数处理,不能静默退化成全局流。"""
from mcm_toolkit.core import get_rng, set_seed
set_seed(1)
first = get_rng(np.int64(42))
second = get_rng(np.int64(42))
assert first is not second, "传 np.integer 应当新建独立生成器"
assert first.random() == np.random.default_rng(42).random()
def test_get_rng_rejects_bad_types(self):
"""类型不对就报错。静默当成"没传种子",换来的只会是不可复现的结果。"""
from mcm_toolkit.core import get_rng
for bad in ["42", 42.0, True, [42]]:
with pytest.raises(TypeError):
get_rng(bad)
def test_get_rng_without_seed_shares_and_advances(self):
from mcm_toolkit.core import get_rng, set_seed
set_seed(3)
assert get_rng() is get_rng(), "无参调用应返回同一个共享生成器"
assert get_rng().random() != get_rng().random(), "共享生成器必须被推进"
def test_get_rng_default_follows_global_seed(self):
"""set_seed 之后,不传 random_state 也必须复现——这是"论文能重跑"的底线。"""
from mcm_toolkit.core import get_rng, set_seed
set_seed(7)
first = get_rng().random()
set_seed(7)
second = get_rng().random()
assert first == second
def test_set_seed_also_covers_third_party_global_state(self):
"""sklearn / statsmodels 走的是 np.random 旧全局,set_seed 要连它一起覆盖。"""
from mcm_toolkit.core import set_seed
set_seed(7)
first = np.random.random() # noqa: NPY002 - 这里就是要验证旧全局被设上了
set_seed(7)
second = np.random.random() # noqa: NPY002
assert first == second
@pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0")
class TestValidate:
def test_check_same_length_raises_with_detail(self):
from mcm_toolkit.core import check_same_length
with pytest.raises(ValueError, match=r"2.*3|长度"):
check_same_length([1, 2], [1, 2, 3])
def test_check_array_rejects_nan(self):
from mcm_toolkit.core import check_array
with pytest.raises(ValueError):
check_array([1.0, np.nan, 2.0], finite=True)
def test_check_probability_bounds(self):
from mcm_toolkit.core import check_probability
assert check_probability(0.5) == 0.5
with pytest.raises(ValueError):
check_probability(1.5)
def test_check_square_matrix(self):
from mcm_toolkit.core import check_square_matrix
check_square_matrix([[1, 2], [3, 4]])
with pytest.raises(ValueError):
check_square_matrix([[1, 2, 3], [4, 5, 6]])
class TestAsArray:
"""`as_array` 是所有收数值函数的统一入口:白名单 + 转 ndarray + 报告原类型。"""
def test_reports_kind_for_every_supported_container(self):
from mcm_toolkit.core import as_array
df = pd.DataFrame({"a": [1.0, 2.0], "city": ["X", "Y"]})
series = pd.Series([1.0, 2.0], index=["u", "v"], name="s")
assert as_array(df)[1] == "frame"
assert as_array(series)[1] == "series"
for raw in (np.array([1.0, 2.0]), [1.0, 2.0], (1.0, 2.0)):
assert as_array(raw)[1] == "array"
def test_frame_keeps_only_numeric_columns_by_default(self):
# numeric_only 默认 True:一列数值都没有就报 ValueError(与 describe / zscore 同口径)
from mcm_toolkit.core import as_array
df = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0], "city": ["X", "Y"]})
arr, kind = as_array(df)
assert arr.shape == (2, 2) and kind == "frame"
assert list(df.select_dtypes(include=[np.number]).columns) == ["a", "b"]
with pytest.raises(ValueError, match="数值"):
as_array(pd.DataFrame({"city": ["X", "Y"]}))
def test_frame_with_text_and_numeric_only_false_raises(self):
# 元素级函数(normalize / standardize / log_transform)走 numeric_only=False,
# 因为跳过列会让返回的表少列、与输入对不上;转不动就该报错,不能静默丢列。
from mcm_toolkit.core import as_array
df = pd.DataFrame({"a": [1.0, 2.0], "city": ["X", "Y"]})
with pytest.raises(TypeError, match="转不成"):
as_array(df, numeric_only=False)
def test_default_never_flattens(self):
# 摊平曾是 t_test(DataFrame) 静默混合多列的根源,必须是显式行为
from mcm_toolkit.core import as_array
df = pd.DataFrame({"a": [1.0, 2.0, 3.0], "b": [4.0, 5.0, 6.0]})
assert as_array(df)[0].shape == (3, 2)
assert as_array(df, ndim=1)[0].shape == (6,)
assert as_array(pd.Series([1.0, 2.0, 3.0]), ndim=1)[0].shape == (3,)
def test_ndim_2_checks_instead_of_reshaping(self):
# 一维数据是"1 个样本"还是"1 个特征"没有唯一答案,不能替调用方猜
from mcm_toolkit.core import as_array
assert as_array([[1.0, 2.0]], ndim=2)[0].shape == (1, 2)
with pytest.raises(ValueError, match="二维"):
as_array([1.0, 2.0], ndim=2)
with pytest.raises(ValueError, match="ndim"):
as_array([1.0, 2.0], ndim=3)
def test_rejects_types_outside_the_whitelist(self):
from mcm_toolkit.core import as_array
# str 也是可迭代对象:不显式挡住,"abc" 会被当成三个字符算下去
for bad in ("abc", {1, 2}, {"a": 1}, None, object()):
with pytest.raises(TypeError):
as_array(bad)
def test_keeps_missing_values(self):
# 删缺失是各函数的**语义**(corr 成对删、t_test 配对要成对删),不能在这里做
from mcm_toolkit.core import as_array
arr = as_array([1.0, np.nan, 3.0])[0]
assert arr.shape == (3,) and np.isnan(arr[1])
def test_dtype_none_keeps_original_dtype(self):
from mcm_toolkit.core import as_array
assert as_array([1, 2, 3], dtype=None)[0].dtype.kind == "i"
assert as_array([1, 2, 3])[0].dtype.kind == "f"
def test_result_repr_is_readable():
from mcm_toolkit.core import Result
text = repr(Result("demo", params={"a": 1}, metrics={"r2": 0.9}))
# 只断言关键信息在场,不断言完整字符串——否则以后调格式会让测试集体变红
assert "demo" in text and "r2" in text
def test_result_repr_stays_short_and_safe():
"""默认值、失败结果、十万级数组:repr 都不能崩,也不能刷屏。"""
from mcm_toolkit.core import Result
assert "empty" in repr(Result("empty"))
failed = repr(Result("boom", success=False, message="求解未收敛"))
assert "success=False" in failed
assert "求解未收敛" in failed
noisy = repr(Result("mc", data=np.arange(100_000)))
assert "ndarray(100000)" in noisy
assert len(noisy) < 200, "data 不应被整个打印出来"
def test_result_summary_is_markdown_table():
from mcm_toolkit.core import Result
result = Result("topsis", params={"n": 3}, metrics={"best": 0.83})
text = result.summary()
lines = text.splitlines()
assert lines[0] == "### topsis"
assert lines[2] == "| 项 | 值 |"
assert "| success | True |" in lines
assert "| params.n | 3 |" in lines
assert "| metrics.best | 0.83 |" in lines
assert result.summary() == text, "summary 必须无副作用、可重复调用"
def test_result_summary_appends_message_on_failure():
from mcm_toolkit.core import Result
text = Result("gm11", success=False, message="样本太少").summary()
assert "| success | False |" in text
assert text.rstrip().endswith("> 样本太少")
+534
View File
@@ -0,0 +1,534 @@
"""data 包测试。
L0 阶段的约定:**只测已实现的行为**。未实现的算法用逐条 skip 标出,
不要用模块级 pytestmark —— 那会把同文件里已实现的函数一起跳过,
让 load_table 这种已经写好的函数变成零覆盖。
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L0/L1"
# ---------------------------------------------------------------- io: load_table
def test_load_table_detects_gbk(tmp_path):
"""国内赛题数据常见 GBK,编码探测必须能兜住。"""
from mcm_toolkit.data import load_table
p = tmp_path / "gbk.csv"
p.write_bytes("城市,人口\n北京,2189\n".encode("gbk"))
df = load_table(p)
assert list(df.columns) == ["城市", "人口"]
def test_load_table_keeps_utf8_readable(tmp_path):
"""UTF-8 文件必须按 UTF-8 读出,不能先试 GBK 变成乱码。"""
from mcm_toolkit.data import load_table
p = tmp_path / "utf8.csv"
p.write_text("省份,GDP增速\n广东,5.8\n", encoding="utf-8")
df = load_table(p)
assert list(df.columns) == ["省份", "GDP增速"]
assert df.loc[0, "省份"] == "广东"
def test_load_table_maps_na_like_tokens_to_nan(tmp_path):
"""赛题里的缺失值长得五花八门,默认 na_values 要全部吃成 NaN。"""
from mcm_toolkit.data import load_table
p = tmp_path / "na.csv"
p.write_text("a,b,c\n1,NA,\n2,-,#N/A\n", encoding="utf-8")
df = load_table(p)
# 外面套 bool():df["b"] 的类型是 Series | DataFrame,直接当条件用 pyright 会报
# "条件操作数无效";套上 bool() 既满足类型检查,又能在它真返回 Series 时当场炸掉。
assert bool(df["b"].isna().all())
assert bool(df["c"].isna().all())
assert bool(df["a"].notna().all())
def test_load_table_respects_explicit_encoding(tmp_path):
"""显式给了 encoding 就照办,不再做探测。"""
from mcm_toolkit.data import load_table
p = tmp_path / "gbk.csv"
p.write_bytes("城市,人口\n上海,2487\n".encode("gbk"))
df = load_table(p, encoding="gbk")
assert list(df.columns) == ["城市", "人口"]
def test_load_table_rejects_unknown_suffix(tmp_path):
"""不支持的扩展名要抛 ValueError,而且要带上后缀名。"""
from mcm_toolkit.data import load_table
p = tmp_path / "data.txt"
p.write_text("a,b\n1,2\n", encoding="utf-8")
with pytest.raises(ValueError, match=r"\.txt"):
load_table(p)
def test_load_table_missing_file_raises(tmp_path):
"""文件不存在时要把路径原样带出来,方便定位。"""
from mcm_toolkit.data import load_table
missing = tmp_path / "nope.csv"
with pytest.raises(FileNotFoundError, match=r"nope\.csv"):
load_table(missing)
# ---------------------------------------------------------------- io: save_table
def test_save_table_round_trip(tmp_path):
"""存进去再读出来,内容要一致,并且返回实际写入的 Path。"""
from mcm_toolkit.data import load_table, save_table
df = pd.DataFrame({"城市": ["北京", "上海"], "人口": [2189.0, 2487.0]})
out = save_table(df, tmp_path / "out.csv")
assert isinstance(out, Path)
assert out.exists()
back = load_table(out)
assert list(back.columns) == ["城市", "人口"]
assert back["人口"].tolist() == [2189.0, 2487.0]
def test_save_table_rejects_non_dataframe(tmp_path):
"""传错类型要当场报错,不要写出一个奇怪的文件。"""
from mcm_toolkit.data import save_table
with pytest.raises(TypeError):
save_table([1, 2, 3], tmp_path / "bad.csv") # type: ignore[arg-type] # 就是要传错
def test_save_table_validates_before_touching_disk(tmp_path):
"""校验必须早于副作用:参数不合法时,一个目录、一个文件都不能留下。"""
from mcm_toolkit.data import save_table
with pytest.raises(ValueError, match=r"\.txt"):
save_table(pd.DataFrame({"a": [1]}), tmp_path / "d1" / "x.txt")
with pytest.raises(TypeError):
save_table([1, 2, 3], tmp_path / "d2" / "x.csv") # type: ignore[arg-type] # 就是要传错
assert sorted(p.name for p in tmp_path.iterdir()) == []
# --------------------------------------------------------------- data: clean
def test_clean_missing_default_drops_any_missing():
"""不传 threshold 时是"只要有缺失就删"。"""
from mcm_toolkit.data import clean_missing
df = pd.DataFrame({"a": [1.0, np.nan, 3.0], "b": [1.0, 2.0, np.nan]})
out = clean_missing(df)
assert len(out) == 1
assert list(out.columns) == ["a", "b"]
def test_clean_missing_threshold_keeps_rows_within_limit():
"""四行三列、阈值 0.5:缺 2/3 的两行要删,剩 2 行。
3 列时 min_non_na 必须是 ceil(3*0.5)=2;写成 int() 截断就会漏删。
"""
from mcm_toolkit.data import clean_missing
df = pd.DataFrame(
{
"a": [1.0, np.nan, np.nan, 4.0],
"b": [1.0, np.nan, np.nan, 4.0],
"c": [1.0, 2.0, np.nan, 4.0],
}
)
out = clean_missing(df, axis=0, threshold=0.5)
assert len(out) == 2
assert out["a"].tolist() == [1.0, 4.0]
def test_clean_missing_axis1_drops_empty_column():
"""axis=1 按列删:整列全空(缺失率 1.0 > 0.5)要掉。"""
from mcm_toolkit.data import clean_missing
df = pd.DataFrame({"x": [1.0, 2.0, 3.0], "y": [np.nan, np.nan, np.nan]})
assert list(clean_missing(df, axis=1, threshold=0.5).columns) == ["x"]
def test_clean_missing_rejects_bad_axis():
from mcm_toolkit.data import clean_missing
with pytest.raises(ValueError, match="axis"):
clean_missing(pd.DataFrame({"a": [1.0]}), axis=2)
def test_clean_missing_rejects_non_dataframe():
from mcm_toolkit.data import clean_missing
with pytest.raises(TypeError):
clean_missing([[1.0, 2.0]])
def test_impute_missing_mean_and_ffill():
from mcm_toolkit.data import impute_missing
df = pd.DataFrame({"a": [1.0, np.nan, 3.0]})
assert impute_missing(df, method="mean")["a"].tolist() == [1.0, 2.0, 3.0]
assert impute_missing(df, method="ffill")["a"].tolist() == [1.0, 1.0, 3.0]
def test_impute_missing_mode_survives_all_nan_column():
"""整列全 NaN 时 mode() 给不出众数:不能崩,也不能硬填一个值进去。"""
from mcm_toolkit.data import impute_missing
df = pd.DataFrame({"a": [np.nan, np.nan], "b": [1.0, np.nan]})
out = impute_missing(df, method="mode")
assert bool(out["a"].isna().all())
assert out["b"].tolist() == [1.0, 1.0]
def test_impute_missing_rejects_unknown_method():
from mcm_toolkit.data import impute_missing
with pytest.raises(ValueError, match="bogus"):
impute_missing(pd.DataFrame({"a": [1.0]}), method="bogus")
def test_impute_missing_rejects_non_dataframe():
from mcm_toolkit.data import impute_missing
with pytest.raises(TypeError):
impute_missing([[1.0, 2.0]])
def test_winsorize_clips_series_to_quantiles():
from mcm_toolkit.data import winsorize
s = pd.Series([1.0, 2.0, 3.0, 4.0, 1000.0])
out = winsorize(s, lower=0.1, upper=0.9)
assert float(out.iloc[0]) == pytest.approx(1.4)
assert float(out.iloc[-1]) == pytest.approx(601.6)
assert float(out.max()) < 1000.0
def test_winsorize_rejects_bad_quantile_order():
from mcm_toolkit.data import winsorize
with pytest.raises(ValueError, match="lower"):
winsorize(pd.Series([1.0, 2.0]), lower=0.9, upper=0.1)
def test_winsorize_rejects_non_frame_with_typeerror():
"""§4.7:类型不对必须抛 TypeError,不能抛 ValueError。"""
from mcm_toolkit.data import winsorize
with pytest.raises(TypeError):
winsorize([1.0, 2.0, 3.0])
def test_detect_outliers_rejects_unknown_method():
"""方法名校验在取默认取值之前,所以这条现在就是绿的。"""
from mcm_toolkit.data import detect_outliers
with pytest.raises(ValueError, match="bogus"):
detect_outliers([1.0, 2.0], method="bogus") # type: ignore[arg-type]
def test_detect_outliers_rejects_bad_type():
"""§4.7:类型不对必须抛 TypeError,不能让它一路飘进 pandas 里去。"""
from mcm_toolkit.data import detect_outliers
for bad in (42, 3.14, "123", None, {"a": [1, 2]}):
with pytest.raises(TypeError):
detect_outliers(bad)
def test_detect_outliers_survives_degenerate_columns():
"""全 NaN 列与单元素列都不能抛异常,判定一律为 False。"""
from mcm_toolkit.data import detect_outliers
df = pd.DataFrame({"all_nan": [np.nan, np.nan, np.nan], "one": [1.0, 2.0, 3.0]})
assert not bool(np.asarray(detect_outliers(df)).any())
assert not bool(np.asarray(detect_outliers([5.0])).any())
assert not bool(np.asarray(detect_outliers([5.0], method="zscore")).any())
def test_detect_outliers_accepts_tuple_as_1d():
"""元组按一维数组型处理,返回值与传 list 一致。"""
from mcm_toolkit.data import detect_outliers
mask = np.asarray(detect_outliers((1.0, 2.0, 3.0, 4.0, 1000.0)))
assert mask.shape == (5,)
assert mask.dtype == bool
assert bool(mask[-1])
# ------------------------ 曾经 xfail 的契约,现已全部实现(留作回归)
#
# 这些用例写的是**文档里已经承诺的行为**。实现没跟上时用 xfail,让它们照样出现在
# 报告里(显示成 x);修好之后会自动变成 XPASS —— 看到 XPASS 就把 mark 摘掉。
#
# 已摘掉:threshold 范围校验、IQR 的 UnboundLocalError、(n,1) 形状、zscore 默认阈值、
# winsorize 的文本列、detect_outliers 的文本列、impute_missing 的 mode 就地修改。
# **当前 0 条 xfail** —— 以后新增契约照旧:先写测试 + xfail,实现跟上就摘 mark。
def test_detect_outliers_finds_planted_points():
"""人工埋 3 个离群点,IQR 法应正好命中 3 个。"""
from mcm_toolkit.data import detect_outliers
x = np.concatenate([np.arange(100.0), [1e6, -1e6, 1e7]])
mask = np.asarray(detect_outliers(x, method="iqr"))
assert int(mask.sum()) == 3
assert bool(mask[-3:].all())
def test_detect_outliers_returns_mask_not_filtered_data():
"""约定:只标不删——返回与原数据同形的布尔掩码。"""
from mcm_toolkit.data import detect_outliers
x = np.arange(50.0)
mask = np.asarray(detect_outliers(x, method="zscore"))
assert mask.shape == x.shape
assert mask.dtype == bool
def test_detect_outliers_zscore_flags_planted_point():
from mcm_toolkit.data import detect_outliers
x = np.concatenate([np.zeros(99), [1e6]])
mask = np.asarray(detect_outliers(x, method="zscore"))
assert int(mask.sum()) == 1
assert bool(mask[-1].all())
def test_detect_outliers_survives_text_columns():
"""赛题表几乎都带文本列:分位数只在数值列上算,文本列恒 False。"""
from mcm_toolkit.data import detect_outliers
df = pd.DataFrame({"city": ["a", "b", "c", "d"], "v": [1.0, 2.0, 3.0, 1000.0]})
mask = detect_outliers(df)
assert int(np.asarray(mask).sum()) == 1
assert not bool(np.asarray(mask["city"]).any())
assert bool(np.asarray(mask["v"]).any())
def test_detect_outliers_all_text_frame_returns_all_false():
"""整表没有数值列时不报错,返回全 False 掩码(语义:这一列没有离群点)。"""
from mcm_toolkit.data import detect_outliers
df = pd.DataFrame({"a": ["x", "y"], "b": ["z", "w"]})
mask = detect_outliers(df)
assert mask.shape == df.shape
assert not bool(np.asarray(mask).any())
def test_winsorize_survives_text_columns():
"""文本列必须原样留着:分位数只在数值列上算。"""
from mcm_toolkit.data import winsorize
df = pd.DataFrame({"city": ["a", "b", "c", "d"], "v": [1.0, 2.0, 3.0, 1000.0]})
out = winsorize(df)
assert out["city"].tolist() == ["a", "b", "c", "d"]
assert float(out["v"].iloc[-1]) < 1000.0
def test_clean_missing_rejects_out_of_range_threshold():
from mcm_toolkit.data import clean_missing
with pytest.raises(ValueError, match="threshold"):
clean_missing(pd.DataFrame({"a": [1.0, np.nan]}), threshold=1.5)
def test_impute_missing_mode_does_not_mutate_input():
"""mode 分支也必须返回新表:不能顺手把调用方的表改了。"""
from mcm_toolkit.data import impute_missing
df = pd.DataFrame({"a": [1.0, np.nan, 1.0]})
out = impute_missing(df, method="mode")
assert out["a"].tolist() == [1.0, 1.0, 1.0]
assert bool(df["a"].isna().any())
# ------------------------------------------------------ data: transform
def test_normalize_minmax_range(rng):
"""minmax 是**按列**压到 [0, 1],不是整表压一次。"""
from mcm_toolkit.data import normalize
x = rng.normal(size=(50, 3))
out = np.asarray(normalize(x, method="minmax"))
assert np.allclose(out.min(axis=0), 0.0)
assert np.allclose(out.max(axis=0), 1.0)
def test_normalize_minmax_doctest_values():
"""docstring 里那个 Examples 就是规格,不能只有形似。"""
from mcm_toolkit.data import normalize
assert normalize([1, 2, 3]).tolist() == [0.0, 0.5, 1.0]
def test_normalize_respects_feature_range():
from mcm_toolkit.data import normalize
out = np.asarray(normalize([1.0, 2.0, 3.0], feature_range=(-1.0, 1.0)))
assert np.allclose(out, [-1.0, 0.0, 1.0])
def test_normalize_constant_column_stays_finite():
"""常量列 max == min,不能出 nan / inf;固定压到 feature_range 的下界。"""
from mcm_toolkit.data import normalize
out = np.asarray(normalize([[2.0, 1.0], [2.0, 3.0], [2.0, 5.0]]))
assert np.allclose(out[:, 0], 0.0)
assert bool(np.isfinite(out).all())
def test_normalize_vector_method_gives_unit_columns():
from mcm_toolkit.data import normalize
out = np.asarray(normalize([[3.0, 0.0], [4.0, 0.0]], method="vector"))
assert np.allclose(np.linalg.norm(out, axis=0), [1.0, 0.0])
def test_normalize_max_method_scales_by_column_max():
from mcm_toolkit.data import normalize
out = np.asarray(normalize([[1.0, 10.0], [2.0, 20.0]], method="max"))
assert np.allclose(out, [[0.5, 0.5], [1.0, 1.0]])
def test_normalize_rejects_bad_method_and_feature_range():
from mcm_toolkit.data import normalize
with pytest.raises(ValueError, match="bogus"):
normalize([1.0, 2.0], method="bogus")
with pytest.raises(ValueError, match="feature_range"):
normalize([1.0, 2.0], feature_range=(1.0, 0.0))
with pytest.raises(TypeError, match="feature_range"):
normalize([1.0, 2.0], feature_range=(0.0, 1.0, 2.0)) # type: ignore[arg-type]
def test_standardize_has_zero_mean_unit_std(rng):
from mcm_toolkit.data import standardize
x = rng.normal(5, 3, size=1000)
out = np.asarray(standardize(x))
assert abs(out.mean()) < 1e-9
assert abs(out.std() - 1.0) < 1e-9
def test_standardize_is_per_column(rng):
"""按列标准化:每列各自均值 0、标准差 1。"""
from mcm_toolkit.data import standardize
x = rng.normal(size=(200, 3)) * [1.0, 10.0, 100.0] + [0.0, 50.0, -3.0]
out = np.asarray(standardize(x))
assert np.allclose(out.mean(axis=0), 0.0, atol=1e-12)
assert np.allclose(out.std(axis=0), 1.0, atol=1e-12)
def test_standardize_honours_ddof():
"""ddof 要真的传下去:ddof=0 才得到单位方差;ddof=1 时结果的 std = sqrt((n-1)/n)。
除以更大的样本标准差,结果自然更小 —— 所以 ddof=1 时 out.std() **小于** 1。
"""
from mcm_toolkit.data import standardize
x = np.array([1.0, 2.0, 3.0, 4.0])
assert np.allclose(standardize(x, ddof=0).std(), 1.0)
assert np.allclose(standardize(x, ddof=1).std(), np.sqrt(3.0 / 4.0))
def test_standardize_constant_column_stays_finite():
from mcm_toolkit.data import standardize
out = np.asarray(standardize([[2.0, 1.0], [2.0, 3.0]]))
assert np.allclose(out[:, 0], 0.0)
assert bool(np.isfinite(out).all())
def test_standardize_rejects_bad_type():
from mcm_toolkit.data import standardize
with pytest.raises(TypeError):
standardize("abc")
def test_log_transform_shifts_to_keep_domain():
"""含 0 或负值时自动取 offset = 1 - min(x),保证 log 的定义域。"""
from mcm_toolkit.data import log_transform
x = np.array([-5.0, 0.0, 5.0])
out = np.asarray(log_transform(x))
assert bool(np.isfinite(out).all())
assert np.allclose(out, np.log(x + 6.0))
def test_log_transform_positive_data_needs_no_offset():
from mcm_toolkit.data import log_transform
assert np.allclose(log_transform([1.0, np.e]), [0.0, 1.0])
def test_log_transform_rejects_bad_type():
from mcm_toolkit.data import log_transform
with pytest.raises(TypeError):
log_transform("abc")
def test_one_hot_encode_auto_detects_text_columns_quietly():
"""pandas 3 起字符串列的 dtype 是 'str':要能选中,且不能走已弃用的 select_dtypes 路径。"""
import warnings
from mcm_toolkit.data import one_hot_encode
df = pd.DataFrame({"city": ["A", "B", "C"], "v": [1.0, 2.0, 3.0]})
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
out = one_hot_encode(df)
messages = [str(w.message) for w in caught]
assert not messages, f"自动识别不该产生任何告警,实际:{messages}"
assert "city_A" in out.columns
assert "v" in out.columns
def test_one_hot_encode_explicit_columns_and_drop_first():
from mcm_toolkit.data import one_hot_encode
df = pd.DataFrame({"city": ["A", "B", "C"], "v": [1.0, 2.0, 3.0]})
out = one_hot_encode(df, columns=["city"], drop_first=True)
assert "city_A" not in out.columns
assert "city_B" in out.columns
assert "city_C" in out.columns
def test_one_hot_encode_rejects_missing_column_and_non_frame():
from mcm_toolkit.data import one_hot_encode
df = pd.DataFrame({"city": ["A"], "v": [1.0]})
with pytest.raises(ValueError, match="nope"):
one_hot_encode(df, columns=["nope"])
with pytest.raises(TypeError):
one_hot_encode([1, 2, 3])
# ------------------------------------------------- 以下为未实现算法(L0/L1 待写)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_train_test_split_sizes_and_no_overlap(rng):
from mcm_toolkit.data import train_test_split
X = rng.normal(size=(100, 2))
Xtr, Xte = train_test_split(X, test_size=0.2, random_state=0)
assert len(Xtr) == 80 and len(Xte) == 20
assert len({tuple(r) for r in Xtr} & {tuple(r) for r in Xte}) == 0
+79
View File
@@ -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 # 后验差一级
+347
View File
@@ -0,0 +1,347 @@
"""models 包测试。"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L1"
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_fit_linear_recovers_known_coefficients(linear_data):
"""真值 y = 2x + 1,拟合系数应在 1% 内。"""
from mcm_toolkit.models import fit_linear
x, y = linear_data
r = fit_linear(x.reshape(-1, 1), y)
assert np.isclose(r.params["coef"][0], 2.0, rtol=0.01)
assert np.isclose(r.params["intercept"], 1.0, atol=0.05)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_fit_polynomial_matches_numpy(linear_data):
from mcm_toolkit.models import fit_polynomial
x, y = linear_data
r = fit_polynomial(x.reshape(-1, 1), y, degree=2)
assert np.allclose(r.params["coef"], np.polyfit(x, y, 2), rtol=1e-6)
def test_metrics_known_values():
from mcm_toolkit.models import mae, r2_score, rmse
y_true = np.array([1.0, 2.0, 3.0])
y_pred = np.array([1.0, 2.0, 5.0])
assert np.isclose(rmse(y_true, y_pred), np.sqrt(4 / 3))
assert np.isclose(mae(y_true, y_pred), 2 / 3)
assert r2_score(y_true, y_pred) < 1.0
def test_r2_is_one_for_perfect_fit():
from mcm_toolkit.models import r2_score
y = np.array([1.0, 2.0, 3.0, 4.0])
assert np.isclose(r2_score(y, y), 1.0)
class _MeanModel:
"""最笨的模型:永远预测训练集的均值(`compare_models` 的对照组)。"""
def fit(self, X, y):
self.mu_ = float(np.mean(y))
return self
def predict(self, X):
return np.full(X.shape[0], self.mu_)
class _LinearModel:
"""一列特征的普通最小二乘(`compare_models` 的"好模型")。"""
def fit(self, X, y):
self.coef_ = np.polyfit(X[:, 0], y, 1)
return self
def predict(self, X):
return np.polyval(self.coef_, X[:, 0])
def test_compare_models_returns_table_sorted_by_first_metric(linear_data):
from mcm_toolkit.models import compare_models
x, y = linear_data
tbl = compare_models(
{"mean": _MeanModel(), "linear": _LinearModel()}, x.reshape(-1, 1), y, cv=0
)
assert list(tbl.columns) == ["rmse", "mae", "r2"]
assert list(tbl.index) == ["linear", "mean"] # 首列 rmse 升序 → 好的模型在前
assert tbl.loc["linear", "rmse"] < tbl.loc["mean", "rmse"]
def test_metrics_reject_mismatched_shapes():
from mcm_toolkit.models import rmse
with pytest.raises(ValueError, match="形状必须一致"):
rmse([1.0, 2.0], [1.0, 2.0, 3.0])
def test_metrics_reject_empty_input():
from mcm_toolkit.models import rmse
with pytest.raises(ValueError, match="不能为空"):
rmse(np.array([]), np.array([]))
def test_metrics_reject_nan_instead_of_dropping_it():
"""含 NaN 一律报错,**绝不静默删点**:删了样本量就变了,指标不可比。"""
from mcm_toolkit.models import rmse
with pytest.raises(ValueError, match="NaN"):
rmse([1.0, np.nan, 3.0], [1.0, 2.0, 3.0])
with pytest.raises(ValueError, match="NaN"):
rmse([1.0, 2.0, 3.0], [1.0, np.nan, 3.0])
def test_metrics_reject_non_whitelisted_type():
"""`str` 也是可迭代对象,必须被入口白名单挡住,不能被当成三个字符算下去。"""
from mcm_toolkit.models import rmse
with pytest.raises(TypeError):
rmse("abc", "abc") # type: ignore[arg-type]
@pytest.mark.parametrize("name", ["rmse", "mae", "mape", "smape", "r2_score"])
def test_metrics_agree_across_input_types(name):
"""`DataFrame` / `Series` / `ndarray` / `list` 四种入参必须给出同一个数。"""
import mcm_toolkit.models as models
yt = [1.0, 2.0, 3.0, 4.0]
yp = [1.1, 1.9, 3.2, 3.8]
fn = getattr(models, name)
base = fn(yt, yp)
assert np.isclose(fn(np.array(yt), np.array(yp)), base)
assert np.isclose(fn(pd.Series(yt), pd.Series(yp)), base)
assert np.isclose(fn(pd.DataFrame({"y": yt}), pd.DataFrame({"y": yp})), base)
def test_mape_rejects_non_positive_y_true():
"""`y_true` 非正时 MAPE 无定义 —— 拦住并点名改用 smape,而不是返回负数或 inf。"""
from mcm_toolkit.models import mape
with pytest.raises(ValueError, match="非正数"):
mape([0.0, 2.0], [1.0, 2.0])
with pytest.raises(ValueError, match="非正数"):
mape([-1.0, 2.0], [1.0, 2.0])
def test_metrics_reject_infinite_input():
"""`Inf` 与 `NaN` 一样直接拦:算出来是 `inf` 的指标没有任何意义。"""
from mcm_toolkit.models import rmse
with pytest.raises(ValueError, match="Inf"):
rmse([1.0, np.inf], [1.0, 2.0])
with pytest.raises(ValueError, match="Inf"):
rmse([1.0, 2.0], [1.0, -np.inf])
def test_metrics_require_at_least_two_samples():
"""1 个点的误差指标没有统计意义;标量也会被这条拦住(不是被当成 1 个样本)。"""
from mcm_toolkit.models import rmse
with pytest.raises(ValueError, match="至少需要包含 2 个样本"):
rmse([1.0], [1.0])
with pytest.raises(ValueError, match="至少需要包含 2 个样本"):
rmse(1.0, 1.0)
def test_smape_is_silent_when_denominator_is_zero():
"""分子分母同时为 0 的点记 0,且**不能漏出 RuntimeWarning**。
本仓库 `pyproject.toml` 里开了 `filterwarnings = ["error::RuntimeWarning"]`,
所以这条测试同时守着"零除"这个坑:`np.where` 的两条分支都会被求值,
直接写除式就会把告警漏出来,测试会红。
"""
from mcm_toolkit.models import smape
assert smape([0.0, 0.0], [0.0, 0.0]) == 0.0
assert smape([0.0, 2.0], [0.0, 2.0]) == 0.0
assert np.isclose(smape([1.0, 1.0], [0.0, 1.0]), 1.0)
def test_adjusted_r2_rejects_invalid_n_features():
from mcm_toolkit.models import adjusted_r2
yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
yp = np.array([1.5, 2.5, 2.5, 4.5, 5.5])
for bad in (True, 1.0, np.float64(1.0)):
with pytest.raises(TypeError):
adjusted_r2(yt, yp, bad) # type: ignore[arg-type]
with pytest.raises(ValueError, match="正整数"):
adjusted_r2(yt, yp, 0)
with pytest.raises(ValueError, match="样本量太小"):
adjusted_r2(yt, yp, 4) # n = 5,分母 n - k - 1 = 0
def test_adjusted_r2_accepts_numpy_integer():
from mcm_toolkit.models import adjusted_r2
yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
yp = np.array([1.5, 2.5, 2.5, 4.5, 5.5])
assert adjusted_r2(yt, yp, np.int64(1)) == adjusted_r2(yt, yp, 1)
def test_adjusted_r2_penalises_extra_features():
"""同一份数据、同一个 R²,自变量越多,调整 R² 越小(没用的变量只吃惩罚)。"""
from mcm_toolkit.models import adjusted_r2
yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
yp = np.array([1.2, 2.1, 3.3, 3.8, 5.1, 6.2])
assert adjusted_r2(yt, yp, 3) < adjusted_r2(yt, yp, 2) < adjusted_r2(yt, yp, 1)
def test_r2_matches_analytic_formula():
"""`R² = 1 - SS_res / SS_tot`,且 `SS_tot` 的基准必须是 **y_true 的均值**。
这里刻意用**非完美拟合**的数据:完美拟合(R² = 1)和常量 y(R² = 0)都能把
"基准量抄错对象"这类 bug 掩盖掉(个人易错点 31)。
"""
from mcm_toolkit.models import r2_score
yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
yp = np.array([1.5, 2.5, 2.5, 4.5, 5.5])
ss_res = np.sum((yt - yp) ** 2)
ss_tot = np.sum((yt - np.mean(yt)) ** 2)
assert np.isclose(r2_score(yt, yp), 1 - ss_res / ss_tot)
assert np.isclose(r2_score(yt, yp), 0.875)
def test_r2_matches_sklearn():
"""拿成熟库当**独立的参照物**:自实现指标最容易"自己算自己"。"""
sk = pytest.importorskip("sklearn.metrics")
from mcm_toolkit.models import r2_score
rng = np.random.default_rng(0)
yt = rng.normal(size=50)
yp = yt + rng.normal(scale=0.5, size=50)
assert np.isclose(r2_score(yt, yp), sk.r2_score(yt, yp))
def test_r2_is_negative_when_worse_than_predicting_the_mean():
from mcm_toolkit.models import r2_score
assert np.isclose(r2_score([1.0, 2.0, 3.0], [3.0, 2.0, 1.0]), -3.0)
def test_adjusted_r2_matches_analytic_formula():
from mcm_toolkit.models import adjusted_r2, r2_score
yt = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
yp = np.array([1.2, 2.1, 3.3, 3.8, 5.1, 6.2])
n, k = len(yt), 2
r2 = r2_score(yt, yp)
assert np.isclose(adjusted_r2(yt, yp, k), 1 - (1 - r2) * (n - 1) / (n - k - 1))
def test_compare_models_treats_r2_names_as_aliases(linear_data):
"""`"r2"` 与 `"r2_score"` 是同一个指标的别名:传哪个、列名就叫哪个,数值相同。"""
from mcm_toolkit.models import compare_models
x, y = linear_data
X = x.reshape(-1, 1)
short = compare_models({"m": _LinearModel()}, X, y, metrics=("rmse", "r2"), cv=0)
full = compare_models({"m": _LinearModel()}, X, y, metrics=("rmse", "r2_score"), cv=0)
assert list(short.columns) == ["rmse", "r2"]
assert list(full.columns) == ["rmse", "r2_score"]
assert np.isclose(short.loc["m", "r2"], full.loc["m", "r2_score"])
def test_compare_models_sorts_descending_when_first_metric_is_r2(linear_data):
"""误差类是**越小越好**、R² 是**越大越好**,排序方向必须跟着首列指标走。"""
from mcm_toolkit.models import compare_models
x, y = linear_data
tbl = compare_models(
{"mean": _MeanModel(), "linear": _LinearModel()},
x.reshape(-1, 1),
y,
metrics=("r2",),
cv=0,
)
assert list(tbl.index) == ["linear", "mean"] # r2 降序 → 好的模型仍在前
def test_compare_models_rejects_bad_metric_names(linear_data):
from mcm_toolkit.models import compare_models
x, y = linear_data
X = x.reshape(-1, 1)
with pytest.raises(ValueError, match="不认识的指标名"):
compare_models({"m": _MeanModel()}, X, y, metrics=("mse",), cv=0)
with pytest.raises(ValueError, match="别名"):
compare_models({"m": _MeanModel()}, X, y, metrics=("r2", "r2_score"), cv=0)
with pytest.raises(TypeError, match="不要传单个字符串"):
compare_models({"m": _MeanModel()}, X, y, metrics="rmse", cv=0) # type: ignore[arg-type]
with pytest.raises(TypeError, match="tuple / list"):
compare_models({"m": _MeanModel()}, X, y, metrics=None, cv=0) # type: ignore[arg-type]
def test_compare_models_rejects_bad_models_and_cv(linear_data):
from mcm_toolkit.models import compare_models
x, y = linear_data
X = x.reshape(-1, 1)
with pytest.raises(TypeError, match="字典"):
compare_models([_MeanModel()], X, y) # type: ignore[arg-type]
with pytest.raises(ValueError, match="不能为空"):
compare_models({}, X, y)
with pytest.raises(TypeError, match="Model 协议"):
compare_models({"bad": object()}, X, y, cv=0)
with pytest.raises(ValueError, match="cv=1"):
compare_models({"m": _MeanModel()}, X, y, cv=1)
with pytest.raises(ValueError, match="太大"):
compare_models({"m": _MeanModel()}, X, y, cv=1000)
with pytest.raises(ValueError, match="样本数必须一致"):
compare_models({"m": _MeanModel()}, X, y[:-1], cv=0)
def test_compare_models_needs_two_samples_per_fold():
"""每折至少 2 个样本(指标自己的入口要求),所以 cv 最多 n // 2。"""
from mcm_toolkit.models import compare_models
X = np.arange(4, dtype=float).reshape(-1, 1)
y = np.arange(4, dtype=float)
assert compare_models({"m": _MeanModel()}, X, y, cv=2, random_state=0).shape == (1, 3)
with pytest.raises(ValueError, match="太大"):
compare_models({"m": _MeanModel()}, X, y, cv=3, random_state=0)
with pytest.raises(ValueError, match="样本太少"):
compare_models({"m": _MeanModel()}, X[:1], y[:1], cv=0)
def test_compare_models_is_reproducible_with_random_state(linear_data):
"""分折用了随机数,所以必须服从 `random_state`(架构 §4.2)。"""
from mcm_toolkit.models import compare_models
x, y = linear_data
X = x.reshape(-1, 1)
models = {"mean": _MeanModel(), "linear": _LinearModel()}
first = compare_models(models, X, y, cv=5, random_state=42)
second = compare_models(models, X, y, cv=5, random_state=42)
assert first.equals(second)
+721
View File
@@ -0,0 +1,721 @@
"""solve 包测试。"""
from __future__ import annotations
import numpy as np
import pytest
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L0/L1"
def _normal_batch(rng, size, **kwargs):
"""`monte_carlo` 的 sampler 契约:收下实现传来的 `size`,一次抽够。"""
return rng.normal(0, 1, size)
def test_monte_carlo_integral_converges_to_analytic_value():
"""∫₀¹ x² dx = 1/3。n=2e5 时误差应 < 0.01。"""
from mcm_toolkit.solve import monte_carlo_integral
r = monte_carlo_integral(lambda X: X[:, 0] ** 2, bounds=[(0, 1)], n=200_000, random_state=0)
assert r.success
assert abs(r.metrics["mean"] - 1 / 3) < 0.01 # 体积必须是正的
assert len(r.data) == 200_000 # data 是 n 个样本各自的贡献
def test_monte_carlo_integral_odd_dimension_sign():
"""∫∫∫ x dV over [0,1]³ = 1/2 —— 奇数维最容易暴露"体积符号写反"。"""
from mcm_toolkit.solve import monte_carlo_integral
r = monte_carlo_integral(lambda X: X[:, 0], bounds=[(0, 1)] * 3, n=200_000, random_state=0)
assert abs(r.metrics["mean"] - 0.5) < 0.01
def test_monte_carlo_integral_signed_bounds():
"""∫₀² x dx = 2 —— 边界带宽度时,体积算错和符号写反都会现形。"""
from mcm_toolkit.solve import monte_carlo_integral
r = monte_carlo_integral(lambda X: X[:, 0], bounds=[(0, 2)], n=200_000, random_state=0)
assert abs(r.metrics["mean"] - 2.0) < 0.02
def test_monte_carlo_integral_2d_and_params():
from mcm_toolkit.solve import monte_carlo_integral
bounds = [(0, 1), (0, 1)]
r = monte_carlo_integral(lambda X: X[:, 0] * X[:, 1], bounds=bounds, n=200_000, random_state=0)
assert abs(r.metrics["mean"] - 0.25) < 0.01
assert r.name == "monte_carlo_integral"
assert r.params["dimension"] == 2
assert r.params["bounds"] == bounds
def test_monte_carlo_integral_rejects_bad_bounds():
"""四类非法 bounds 都要在入口抛 ValueError:区间反了 / 漏括号 / 空 / 参差不齐。"""
from mcm_toolkit.solve import monte_carlo_integral
for bad in ([(1, 0)], [0, 1], [], [(0, 1), (0, 2, 3)]):
with pytest.raises(ValueError):
monte_carlo_integral(lambda X: X[:, 0], bounds=bad, n=100, random_state=0)
def test_monte_carlo_integral_failure_message_stays_failure():
"""失败时 message 不能被包装层改写成"成功"(易错点 22)。"""
from mcm_toolkit.solve import monte_carlo_integral
r = monte_carlo_integral(lambda X: 1.0, bounds=[(0, 1)], n=100, random_state=0)
assert r.success is False
assert "成功" not in r.message
assert "失败" in r.message
assert r.params["dimension"] == 1 # 失败也要留下追溯信息
def test_monte_carlo_integral_accepts_numpy_integer_n():
"""§4.7:`np.int64` 不是 `int` 的子类,注解与运行期都要收(易错点 1)。"""
from mcm_toolkit.solve import monte_carlo_integral
r = monte_carlo_integral(lambda X: X[:, 0], bounds=[(0, 1)], n=np.int64(1000), random_state=0)
assert r.success
assert len(r.data) == 1000
def test_monte_carlo_probability_of_standard_normal():
"""P(Z > 0) = 0.5,n=5000 时误差应 < 0.05。"""
from mcm_toolkit.solve import monte_carlo_probability
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=5000, random_state=0)
assert r.success
assert abs(r.metrics["probability"] - 0.5) < 0.05
def test_monte_carlo_is_reproducible():
from mcm_toolkit.solve import monte_carlo_probability
a = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=1000, random_state=9)
b = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=1000, random_state=9)
assert a.metrics["probability"] == b.metrics["probability"]
def test_monte_carlo_returns_n_results_and_documented_metrics():
from mcm_toolkit.solve import monte_carlo
r = monte_carlo(_normal_batch, lambda x: x**2, n=2000, random_state=0)
assert r.success
assert r.data.shape == (2000,)
assert set(r.metrics) == {"mean", "std", "median", "p05", "p95", "ci_low", "ci_high"}
def test_monte_carlo_calls_simulator_only_once():
"""`simulator` 只被调用一次 —— 这是"必须向量化"的可执行版本。"""
from mcm_toolkit.solve import monte_carlo
counter = {"calls": 0}
def simulator(params):
counter["calls"] += 1
return params
r = monte_carlo(_normal_batch, simulator, n=1000, random_state=0)
assert r.success
assert counter["calls"] == 1
def test_monte_carlo_data_is_reproducible_with_same_seed():
from mcm_toolkit.solve import monte_carlo
a = monte_carlo(_normal_batch, lambda x: x, n=2000, random_state=11)
b = monte_carlo(_normal_batch, lambda x: x, n=2000, random_state=11)
assert np.array_equal(a.data, b.data)
def test_monte_carlo_follows_set_seed_when_random_state_omitted():
from mcm_toolkit.core import set_seed
from mcm_toolkit.solve import monte_carlo
set_seed(5)
a = monte_carlo(_normal_batch, lambda x: x, n=500)
set_seed(5)
b = monte_carlo(_normal_batch, lambda x: x, n=500)
assert np.array_equal(a.data, b.data)
def test_monte_carlo_estimates_known_expectation():
"""E[X²] = 1(X ~ N(0, 1));n = 2e5 时误差应 < 0.02。"""
from mcm_toolkit.solve import monte_carlo
r = monte_carlo(_normal_batch, lambda x: x**2, n=200_000, random_state=0)
assert abs(r.metrics["mean"] - 1.0) < 0.02
def test_monte_carlo_docstring_example_runs():
"""docstring 的 Examples 必须真的能跑通(此前那一版是坏的)。"""
from mcm_toolkit.solve import monte_carlo
draw = lambda rng, size, **k: rng.normal(0, 1, size) # noqa: E731
r = monte_carlo(draw, lambda x: x**2, n=10000, random_state=0)
assert r.success
assert abs(r.metrics["mean"] - 1.0) < 0.1
def test_monte_carlo_accepts_numpy_integer_n():
"""§4.7:`np.int64` 不是 `int` 的子类,不能漏。"""
from mcm_toolkit.solve import monte_carlo
r = monte_carlo(_normal_batch, lambda x: x, n=np.int64(100), random_state=0)
assert r.success
assert r.data.shape == (100,)
def test_monte_carlo_counts_boolean_outcomes():
"""布尔结果按 0/1 统计 —— 事件概率是 MC 最常见的用法。"""
from mcm_toolkit.solve import monte_carlo
r = monte_carlo(
lambda rng, size, **k: rng.normal(0, 1, size) > 0,
lambda x: x,
n=5000,
random_state=0,
)
assert r.success
assert r.data.dtype == float
assert abs(r.metrics["mean"] - 0.5) < 0.05
def test_monte_carlo_rejects_nan_results():
"""结果里出现 NaN 就判失败,不静默给出一列 nan 指标。"""
from mcm_toolkit.solve import monte_carlo
r = monte_carlo(
lambda rng, size, **k: np.r_[np.zeros(size - 1), np.nan],
lambda x: x,
n=10,
random_state=0,
)
assert not r.success
assert "NaN" in r.message
def test_monte_carlo_rejects_bad_arguments():
from mcm_toolkit.solve import monte_carlo
with pytest.raises(TypeError):
monte_carlo(123, lambda x: x) # type: ignore[arg-type] # 就是要传错
with pytest.raises(TypeError):
monte_carlo(_normal_batch, 123) # type: ignore[arg-type] # 就是要传错
with pytest.raises(TypeError):
monte_carlo(_normal_batch, lambda x: x, n=True)
with pytest.raises(TypeError):
monte_carlo(_normal_batch, lambda x: x, n=10.0) # type: ignore[arg-type] # 就是要传错
with pytest.raises(ValueError):
monte_carlo(_normal_batch, lambda x: x, n=0)
def test_monte_carlo_probability_returns_documented_metrics():
from mcm_toolkit.solve import monte_carlo_probability
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=20000, random_state=0)
assert r.success
assert set(r.metrics) == {"probability", "ci_low", "ci_high", "std_error"}
assert r.data.shape == (20000,)
assert 0.0 <= r.metrics["ci_low"] <= r.metrics["probability"]
assert r.metrics["probability"] <= r.metrics["ci_high"] <= 1.0
def test_monte_carlo_probability_std_error_matches_binomial_formula():
"""`std_error` 就是比例的标准误 √(p(1-p)/n)。"""
from mcm_toolkit.solve import monte_carlo_probability
n = 20000
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=n, random_state=0)
p = r.metrics["probability"]
assert np.isclose(r.metrics["std_error"], np.sqrt(p * (1 - p) / n))
def test_monte_carlo_probability_ci_narrows_with_n():
"""区间宽度随 n 增大而收窄(O(1/√n))。"""
from mcm_toolkit.solve import monte_carlo_probability
def width(n):
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=n, random_state=0)
return r.metrics["ci_high"] - r.metrics["ci_low"]
assert width(100_000) < width(1_000)
def test_monte_carlo_probability_higher_confidence_is_wider():
from mcm_toolkit.solve import monte_carlo_probability
def width(confidence):
r = monte_carlo_probability(
lambda z: z > 0, _normal_batch, n=5000, random_state=0, confidence=confidence
)
return r.metrics["ci_high"] - r.metrics["ci_low"]
assert width(0.99) > width(0.90)
def test_monte_carlo_probability_ci_matches_statsmodels_wilson():
"""区间必须对得上独立实现(statsmodels 的 wilson)—— `^` 写成异或那次就靠它拦住。"""
from statsmodels.stats.proportion import proportion_confint
from mcm_toolkit.solve import monte_carlo_probability
n = 5000
r = monte_carlo_probability(lambda z: z > 0, _normal_batch, n=n, random_state=0)
count = int(r.data.sum())
lo, hi = proportion_confint(count, n, alpha=0.05, method="wilson")
assert np.isclose(r.metrics["ci_low"], lo)
assert np.isclose(r.metrics["ci_high"], hi)
def test_monte_carlo_probability_rejects_bad_confidence():
from mcm_toolkit.solve import monte_carlo_probability
for bad in (0.0, 1.0, -0.5, 1.5):
with pytest.raises(ValueError):
monte_carlo_probability(lambda z: z > 0, _normal_batch, n=100, confidence=bad)
def test_random_walk_shape_and_start():
"""`data` 形状 (n_paths, n_steps + 1),第 0 列是起点。"""
from mcm_toolkit.solve import random_walk
r = random_walk(n_steps=100, n_paths=5, start=2.5, random_state=0)
assert r.success
assert r.data.shape == (5, 101)
assert np.all(r.data[:, 0] == 2.5)
def test_random_walk_default_steps_are_plus_minus_one():
"""默认是对称随机游走:每一步都恰好是 ±1。"""
from mcm_toolkit.solve import random_walk
r = random_walk(n_steps=200, n_paths=3, random_state=0)
assert set(np.unique(np.diff(r.data, axis=1))) == {-1.0, 1.0}
def test_random_walk_is_reproducible():
from mcm_toolkit.solve import random_walk
a = random_walk(n_steps=50, n_paths=4, random_state=7)
b = random_walk(n_steps=50, n_paths=4, random_state=7)
assert np.array_equal(a.data, b.data)
def test_random_walk_accepts_custom_step_sampler():
"""换成高斯增量就是带漂移的高斯游走 —— 建模差异全在 step_sampler 里。"""
from mcm_toolkit.solve import random_walk
r = random_walk(
n_steps=100,
n_paths=3,
step_sampler=lambda rng, size: rng.normal(0.5, 1.0, size),
random_state=0,
)
assert r.success
assert r.data.shape == (3, 101)
assert abs(r.data[:, -1].mean() - 50.0) < 20.0 # 100 步 × 漂移 0.5
def test_random_walk_rejects_bad_arguments():
"""§4.7 的分工:类型不对抛 TypeError,取值不对(<= 0)抛 ValueError。"""
from mcm_toolkit.solve import random_walk
with pytest.raises(TypeError):
random_walk(n_steps=2.5) # type: ignore[arg-type] # 就是要传错
with pytest.raises(TypeError):
random_walk(n_steps=True)
with pytest.raises(TypeError):
random_walk(n_steps=10, n_paths=1.5) # type: ignore[arg-type] # 就是要传错
with pytest.raises(ValueError):
random_walk(n_steps=0)
with pytest.raises(ValueError):
random_walk(n_steps=10, n_paths=0)
with pytest.raises(TypeError):
random_walk(n_steps=10, start="a") # type: ignore[arg-type] # 就是要传错
def test_random_walk_rejects_2d_step_sampler():
"""`step_sampler` 必须返回**一维** —— 二维返回(含方阵)一律判失败(易错点 25 的根治)。"""
from mcm_toolkit.solve import random_walk
def transposed(rng, size):
return rng.normal(0, 1, (4, 3)) # 无论要多少,一律给二维
r = random_walk(n_steps=4, n_paths=3, step_sampler=transposed, random_state=0)
assert r.success is False # 失败也要返回 Result,不能抛给调用方
assert "长度" in r.message
assert r.params["n_paths"] == 3 # 失败也要留下追溯信息
assert r.data is None
# 旧方案在 n_paths == n_steps 时对"转置"完全无感;现在同样拦住
assert random_walk(n_steps=3, n_paths=3, step_sampler=transposed, random_state=0).success is False
def test_random_walk_step_sampler_gets_flat_size_and_is_row_major():
"""契约:`size` 是整数 `n_paths * n_steps`,一维增量按**行主序**切成 n_paths 行。"""
from mcm_toolkit.solve import random_walk
seen = {}
def sampler(rng, size):
seen["size"] = size
return np.arange(1, size + 1, dtype=float) # 1, 2, 3, 4, 5, 6
r = random_walk(n_steps=3, n_paths=2, step_sampler=sampler, random_state=0)
assert seen["size"] == 6 # 整数,不是 (n_paths, n_steps)
assert r.data.tolist() == [[0, 1, 3, 6], [0, 4, 9, 15]] # cumsum(1,2,3) / cumsum(4,5,6)
def test_random_walk_rejects_nan_steps():
"""增量含 NaN 判失败 —— 与 monte_carlo 的 NaN 口径(决策 48)一致。"""
from mcm_toolkit.solve import random_walk
r = random_walk(
n_steps=4,
n_paths=2,
step_sampler=lambda rng, size: np.full(size, np.nan),
random_state=0,
)
assert r.success is False
assert "NaN" in r.message
def test_random_walk_accepts_numpy_integer():
"""§4.7:`np.int64` 不是 `int` 的子类,注解与运行期都要收(易错点 1)。"""
from mcm_toolkit.solve import random_walk
r = random_walk(n_steps=np.int64(20), n_paths=np.int64(3), random_state=0)
assert r.success
assert r.data.shape == (3, 21)
def test_random_walk_metrics_are_terminal_positions():
"""终点位置的标准差按 √t 扩散(t = n_steps)—— 随机游走的核心性质。"""
from mcm_toolkit.solve import random_walk
n_steps = 100
r = random_walk(n_steps=n_steps, n_paths=2000, start=5.0, random_state=0)
assert abs(r.metrics["mean_terminal"] - 5.0) < 5.0 # 对称游走,期望还在起点附近
assert abs(r.metrics["std_terminal"] - np.sqrt(n_steps)) < 5.0 # √t 扩散
def test_random_walk_docstring_example_runs():
from mcm_toolkit.solve import random_walk
r = random_walk(n_steps=100, n_paths=5, random_state=0)
assert r.data.shape == (5, 101)
assert np.all(np.abs(np.diff(r.data, axis=1)) == 1)
def test_markov_simulate_shape_start_and_states():
"""`data` 形状 (n_paths, steps + 1),第 0 列是初始状态,取值只能是合法状态编号。"""
from mcm_toolkit.solve import markov_simulate
P = np.array([[0.9, 0.1], [0.2, 0.8]])
r = markov_simulate(P, initial=0, steps=100, n_paths=5, random_state=0)
assert r.success
assert r.data.shape == (5, 101)
assert np.all(r.data[:, 0] == 0)
assert set(np.unique(r.data)) <= {0, 1}
assert np.issubdtype(r.data.dtype, np.integer)
def test_markov_simulate_absorbing_states_never_move():
"""单位转移矩阵:状态自锁,整条路径恒等于初始状态(确定性自检)。"""
from mcm_toolkit.solve import markov_simulate
r = markov_simulate(np.eye(2), initial=1, steps=30, n_paths=4, random_state=0)
assert r.success
assert np.all(r.data == 1)
assert r.metrics["mean_terminal"] == 1.0
assert r.metrics["terminal_distribution"] == {1: 1.0}
def test_markov_simulate_is_reproducible():
from mcm_toolkit.solve import markov_simulate
P = np.array([[0.7, 0.3], [0.4, 0.6]])
a = markov_simulate(P, initial=0, steps=40, n_paths=6, random_state=11)
b = markov_simulate(P, initial=0, steps=40, n_paths=6, random_state=11)
c = markov_simulate(P, initial=0, steps=40, n_paths=6, random_state=12)
assert np.array_equal(a.data, b.data)
assert not np.array_equal(a.data, c.data)
def test_markov_simulate_terminal_frequency_matches_stationary():
"""终点频率收敛到平稳分布 [2/3, 1/3] —— 马尔可夫链模拟的核心性质。"""
from mcm_toolkit.solve import markov_simulate
P = np.array([[0.9, 0.1], [0.2, 0.8]])
r = markov_simulate(P, initial=0, steps=200, n_paths=20000, random_state=0)
freq = np.bincount(r.data[:, -1], minlength=2) / r.data.shape[0]
assert np.allclose(freq, [2 / 3, 1 / 3], atol=0.02)
def test_markov_simulate_metrics_are_terminal_states():
"""`metrics` 只看终点:均值 + `{状态: 频率}`(频率之和为 1、与逐条统计一致)。"""
from mcm_toolkit.solve import markov_simulate
P = np.array([[0.9, 0.1], [0.2, 0.8]])
r = markov_simulate(P, initial=0, steps=50, n_paths=500, random_state=3)
terminal = r.data[:, -1]
dist = r.metrics["terminal_distribution"]
assert r.metrics["mean_terminal"] == float(np.mean(terminal))
assert np.isclose(sum(dist.values()), 1.0)
for state, freq in dist.items():
assert np.isclose(freq, float(np.mean(terminal == state)))
def test_markov_simulate_terminal_distribution_only_lists_seen_states():
"""只含实际出现过的状态(没出现的状态没有 key)—— docstring 已写明这条口径。"""
from mcm_toolkit.solve import markov_simulate
r = markov_simulate(np.eye(3), initial=2, steps=5, n_paths=4, random_state=0)
assert r.metrics["terminal_distribution"] == {2: 1.0}
def test_markov_simulate_rejects_bad_transition():
"""非方阵、行和不为 1 都要在入口直接报错,不能悄悄模拟。"""
from mcm_toolkit.solve import markov_simulate
with pytest.raises(ValueError):
markov_simulate(np.array([[0.5, 0.5, 0.0], [0.2, 0.8, 0.0]]), initial=0)
with pytest.raises(ValueError):
markov_simulate(np.array([[0.5, 0.4], [0.2, 0.8]]), initial=0)
with pytest.raises(ValueError):
markov_simulate(np.array([[1.5, -0.5], [0.0, 1.0]]), initial=0) # 行和是 1,但含负元素
def test_markov_simulate_rejects_bad_arguments():
"""§4.7:步数 / 路径数的类型与正负都要卡住(bool 先判,易错点 1)。"""
from mcm_toolkit.solve import markov_simulate
P = np.array([[0.9, 0.1], [0.2, 0.8]])
with pytest.raises(ValueError):
markov_simulate(P, initial=0, steps=0)
with pytest.raises(ValueError):
markov_simulate(P, initial=0, n_paths=0)
with pytest.raises(TypeError):
markov_simulate(P, initial=0, steps=1.5) # type: ignore[arg-type] # 就是要传错
with pytest.raises(TypeError):
markov_simulate(P, initial=0, n_paths=1.5) # type: ignore[arg-type] # 就是要传错
with pytest.raises(TypeError):
markov_simulate(P, initial=0, steps=True) # type: ignore[arg-type] # 就是要传错
with pytest.raises(TypeError):
markov_simulate(P, initial=0, n_paths=True) # type: ignore[arg-type] # 就是要传错
def test_markov_simulate_accepts_numpy_integer():
from mcm_toolkit.solve import markov_simulate
P = np.array([[0.9, 0.1], [0.2, 0.8]])
r = markov_simulate(P, initial=0, steps=np.int64(10), n_paths=np.int64(2), random_state=0)
assert r.success
assert r.data.shape == (2, 11)
def test_markov_simulate_docstring_example_runs():
from mcm_toolkit.solve import markov_simulate
P = np.array([[0.9, 0.1], [0.2, 0.8]])
r = markov_simulate(P, initial=0, steps=50, n_paths=4, random_state=0)
assert r.data.shape == (4, 51)
assert np.all(r.data[:, 0] == 0)
def test_markov_steady_state_two_state_chain():
"""P=[[0.9,0.1],[0.2,0.8]] 的平稳分布是 [2/3, 1/3]。"""
from mcm_toolkit.solve import markov_steady_state
r = markov_steady_state(np.array([[0.9, 0.1], [0.2, 0.8]]))
assert r.success
assert np.allclose(r.data, [2 / 3, 1 / 3], atol=1e-6)
assert r.metrics["converged"] is True
assert r.metrics["iterations"] > 0
def test_markov_steady_state_matches_eigenvector():
"""与特征分解对拍:取特征值 1 对应的左特征向量(只是检验手段,实现仍走迭代)。"""
from mcm_toolkit.solve import markov_steady_state
P = np.array([[0.5, 0.25, 0.25], [0.1, 0.8, 0.1], [0.2, 0.2, 0.6]])
eigenvalues, eigenvectors = np.linalg.eig(P.T)
ref = np.real(eigenvectors[:, int(np.argmin(np.abs(eigenvalues - 1.0)))])
ref = ref / ref.sum()
r = markov_steady_state(P)
assert np.allclose(r.data, ref, atol=1e-6)
def test_markov_steady_state_is_a_distribution():
from mcm_toolkit.solve import markov_steady_state
P = np.array([[0.7, 0.2, 0.1], [0.1, 0.6, 0.3], [0.05, 0.15, 0.8]])
r = markov_steady_state(P)
assert r.data.shape == (3,)
assert np.isclose(float(np.sum(r.data)), 1.0)
assert np.all(r.data >= 0.0)
assert r.data.dtype == np.float64
def test_markov_steady_state_slow_mixing_needs_more_iterations():
"""慢混合链:迭代次数显著变多,但结果仍要对得上特征向量(tol 是相邻差口径)。"""
from mcm_toolkit.solve import markov_steady_state
P = np.array([[0.999, 0.001], [0.02, 0.98]])
r = markov_steady_state(P)
assert r.metrics["iterations"] > 100
assert np.allclose(r.data, [0.9523809524, 0.0476190476], atol=1e-6)
def test_markov_steady_state_accepts_numpy_integer():
"""§4.7:`np.int64` 不是 `int` 的子类,注解与运行期都要收(易错点 1)。"""
from mcm_toolkit.solve import markov_steady_state
P = np.array([[0.9, 0.1], [0.2, 0.8]])
r = markov_steady_state(P, max_iter=np.int64(10000))
assert r.success
assert r.metrics["converged"] is True
def test_markov_steady_state_rejects_bad_transition():
from mcm_toolkit.solve import markov_steady_state
with pytest.raises(TypeError):
markov_steady_state(np.ones((2, 3)) / 3) # 形状不对 → TypeError(口径见决策 57)
with pytest.raises(ValueError):
markov_steady_state(np.array([[0.5, 0.4], [0.2, 0.8]]))
with pytest.raises(ValueError):
markov_steady_state(np.array([[1.5, -0.5], [0.0, 1.0]])) # 行和是 1,但含负元素
def test_markov_steady_state_non_convergence_is_success_false():
"""`max_iter` 不够时按"未收敛"处理:`success=False` 且 `converged=False`(口径 B)。"""
from mcm_toolkit.solve import markov_steady_state
P = np.array([[0.9, 0.1], [0.2, 0.8]])
r = markov_steady_state(P, max_iter=3)
assert r.success is False
assert r.metrics["converged"] is False
assert r.metrics["iterations"] == 3
assert "未在最大迭代次数内收敛" in r.message
def test_markov_steady_state_rejects_bad_arguments():
"""取值错一律 `ValueError`(类型错要不要拆 `TypeError` 待定,见 STATUS 决策 57)。"""
from mcm_toolkit.solve import markov_steady_state
P = np.array([[0.9, 0.1], [0.2, 0.8]])
with pytest.raises(ValueError):
markov_steady_state(P, max_iter=0)
with pytest.raises(ValueError):
markov_steady_state(P, tol=0)
def test_markov_steady_state_docstring_example_runs():
from mcm_toolkit.solve import markov_steady_state
P = np.array([[0.9, 0.1], [0.2, 0.8]])
r = markov_steady_state(P)
assert bool(np.allclose(r.data, [2 / 3, 1 / 3], atol=1e-6))
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_knapsack_dp_known_optimum():
from mcm_toolkit.solve import knapsack_dp
r = knapsack_dp(weights=[1, 3, 4, 5], values=[1, 4, 5, 7], capacity=7)
assert np.isclose(r.metrics["total_value"], 9.0)
assert np.isclose(r.metrics["total_weight"], 7.0)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_linear_program_basic():
from mcm_toolkit.solve import linear_program
r = linear_program(c=[1, 2], A_ub=[[1, 1]], b_ub=[4], bounds=[(0, None)] * 2)
assert r.success
assert np.isclose(r.metrics["objective"], 4.0)
def test_mm1_metrics_closed_form():
"""与闭式公式逐项对拍(ρ = λ/μ,L = ρ/(1-ρ),Wq = ρ/(μ-λ),W = Wq + 1/μ)。"""
from mcm_toolkit.solve import mm1_metrics
arrival_rate, service_rate = 2, 3
rho = arrival_rate / service_rate
r = mm1_metrics(arrival_rate=arrival_rate, service_rate=service_rate)
assert r.success
assert np.isclose(r.metrics["rho"], rho, rtol=1e-12)
assert np.isclose(r.metrics["L"], rho / (1 - rho), rtol=1e-12)
assert np.isclose(r.metrics["Lq"], rho**2 / (1 - rho), rtol=1e-12)
assert np.isclose(r.metrics["W"], 1 / (service_rate - arrival_rate), rtol=1e-12)
assert np.isclose(r.metrics["Wq"], rho / (service_rate - arrival_rate), rtol=1e-12)
assert np.isclose(r.metrics["W"], r.metrics["Wq"] + 1 / service_rate, rtol=1e-12)
def test_mm1_rejects_unstable_system():
"""λ >= μ 时系统不稳定,应 success=False 而不是返回 inf(λ == μ 也要走这一支)。"""
from mcm_toolkit.solve import mm1_metrics
equal = mm1_metrics(arrival_rate=3, service_rate=3)
assert equal.success is False
assert "不稳定" in equal.message
assert equal.metrics == {"rho": 1.0} # 失败时也把 rho 报出来(决策 58 的 ④)
assert equal.data is None
assert mm1_metrics(arrival_rate=3, service_rate=2).success is False
def test_mm1_allows_zero_arrival_rate_as_empty_system():
"""λ = 0 是合法退化解:空系统(L = Lq = Wq = 0,W 取极限值 1/μ)。"""
from mcm_toolkit.solve import mm1_metrics
r = mm1_metrics(arrival_rate=0, service_rate=3)
assert r.success
assert r.metrics["rho"] == 0.0
assert r.metrics["L"] == 0.0
assert r.metrics["Lq"] == 0.0
assert r.metrics["Wq"] == 0.0
assert np.isclose(r.metrics["W"], 1 / 3)
def test_mm1_rejects_bad_arguments():
"""λ 非负、μ 必须为正;bool 与非数值都要在入口拦下。"""
from mcm_toolkit.solve import mm1_metrics
with pytest.raises(ValueError):
mm1_metrics(arrival_rate=-1, service_rate=3)
with pytest.raises(ValueError):
mm1_metrics(arrival_rate=2, service_rate=0)
with pytest.raises(TypeError):
mm1_metrics(arrival_rate=True, service_rate=3)
with pytest.raises(TypeError):
mm1_metrics(arrival_rate=2, service_rate="3") # type: ignore[arg-type] # 就是要传错
def test_mm1_docstring_example_runs():
from mcm_toolkit.solve import mm1_metrics
r = mm1_metrics(arrival_rate=2, service_rate=3)
assert round(r.metrics["rho"], 4) == 0.6667
def test_mm1_stability_boundary_is_success_not_exception():
"""λ == μ 不能靠 ZeroDivisionError 落到失败分支:必须是显式的"不稳定"口径。"""
from mcm_toolkit.solve import mm1_metrics
r = mm1_metrics(arrival_rate=5, service_rate=5)
assert r.success is False
assert "ZeroDivisionError" not in r.message
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_simulated_annealing_finds_quadratic_minimum():
from mcm_toolkit.solve import simulated_annealing
f = lambda v: (v[0] - 3.0) ** 2 + (v[1] + 1.0) ** 2 # noqa: E731
r = simulated_annealing(f, x0=[0.0, 0.0], n_iter=5000, random_state=0)
assert np.allclose(r.params["x"], [3.0, -1.0], atol=0.1)
+602
View File
@@ -0,0 +1,602 @@
"""stats 包测试。
原则(见 docs/02-architecture.md §5):**测已知解析解,不测实现细节**。
未实现的函数**逐条** `skip`——**不要**用模块级 `pytestmark`:那会把同文件里
已经写好的函数一起跳过,绿灯就是假的(决策 26 / 个人易错点 9)。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L1"
def _pearson_p_from_r(r: float, n: int) -> float:
"""Pearson 相关系数的 p 值**解析式**(测解析解,不测实现细节):
``t = r * sqrt((n - 2) / (1 - r^2))``,``p = 2 * P(T > |t|)``,``T ~ t(n - 2)``。
"""
from scipy import stats as sps
t_stat = r * np.sqrt((n - 2) / (1 - r**2))
return float(2 * sps.t.sf(abs(t_stat), n - 2))
def test_bootstrap_ci_matches_normal_theory_on_large_n(rng):
"""大样本下 bootstrap 百分位区间应逼近正态解析区间(±10% 容差)。"""
from mcm_toolkit.stats import bootstrap_ci
x = rng.normal(10, 2, size=5000)
r = bootstrap_ci(x, statistic=np.mean, confidence=0.95, n_resamples=5000, random_state=0)
se = x.std(ddof=1) / np.sqrt(x.size)
assert abs((r.params["high"] - r.params["low"]) - 2 * 1.96 * se) < 0.1 * 2 * 1.96 * se
def test_bootstrap_ci_works_for_arbitrary_statistic(rng):
"""bootstrap 的核心价值:任意统计量都能给区间(这里用中位数)。"""
from mcm_toolkit.stats import bootstrap_ci
x = rng.lognormal(0, 1, size=2000)
r = bootstrap_ci(x, statistic=np.median, random_state=0)
assert r.params["low"] < r.params["high"]
assert r.params["low"] < np.median(x) < r.params["high"]
def test_bootstrap_ci_is_reproducible(rng):
from mcm_toolkit.stats import bootstrap_ci
x = rng.normal(size=500)
a = bootstrap_ci(x, statistic=np.mean, random_state=123)
b = bootstrap_ci(x, statistic=np.mean, random_state=123)
assert a.params["low"] == b.params["low"]
def test_bootstrap_ci_reports_the_original_estimate(rng):
"""`params["estimate"]` 必须是**原样本**上的统计量,不是重采样分布的均值。"""
from mcm_toolkit.stats import bootstrap_ci
x = rng.normal(10, 2, size=200)
r = bootstrap_ci(x, statistic=np.mean, n_resamples=500, random_state=0)
assert r.params["estimate"] == pytest.approx(float(np.mean(x)))
assert set(r.params) == {"estimate", "low", "high", "confidence", "n_resamples"}
assert r.params["n_resamples"] == 500
assert r.metrics["statistic_name"] == "mean"
def test_bootstrap_ci_interval_widens_with_confidence(rng):
"""置信水平越高区间必须越宽 —— 结构性检查,不依赖任何具体分布。"""
from mcm_toolkit.stats import bootstrap_ci
x = rng.normal(size=300)
width = [
bootstrap_ci(x, np.mean, confidence=c, n_resamples=2000, random_state=7).params["high"]
- bootstrap_ci(x, np.mean, confidence=c, n_resamples=2000, random_state=7).params["low"]
for c in (0.80, 0.95)
]
assert width[0] < width[1]
def test_bootstrap_ci_accepts_the_documented_containers(rng):
"""DataFrame 只取数值列后摊平;Series / ndarray / list 都收。"""
from mcm_toolkit.stats import bootstrap_ci
x = rng.normal(size=200)
ref = bootstrap_ci(x, np.mean, n_resamples=500, random_state=0).params
for wrapped in (pd.Series(x), pd.DataFrame({"a": x}), list(x), tuple(x)):
got = bootstrap_ci(wrapped, np.mean, n_resamples=500, random_state=0).params
assert got["low"] == pytest.approx(ref["low"])
def test_bootstrap_ci_works_for_a_custom_lambda(rng):
"""不认识的统计量走逐样本回退分支(这里是极差)。"""
from mcm_toolkit.stats import bootstrap_ci
x = rng.normal(size=200)
r = bootstrap_ci(x, statistic=lambda s: float(s.max() - s.min()), n_resamples=500, random_state=0)
# 极差的重采样值**永远不会超过原样本极差**(原样本本身就是上界),
# 所以百分位区间的上界会正好落在 estimate 上,不能再断言 estimate < high。
assert r.params["estimate"] == pytest.approx(float(x.max() - x.min()))
assert r.params["low"] > 0
assert r.params["low"] < r.params["high"] <= r.params["estimate"]
assert r.metrics["statistic_name"] == "custom"
def test_bootstrap_ci_follows_get_rng_when_no_seed_is_given(rng):
"""不给种子时必须跟随 `get_rng` 的模块级生成器(决策 24),不能另开一个 default_rng。"""
from mcm_toolkit.core import set_seed
from mcm_toolkit.stats import bootstrap_ci
x = rng.normal(size=200)
set_seed(0)
first = bootstrap_ci(x, np.mean, n_resamples=300).params["low"]
set_seed(0)
second = bootstrap_ci(x, np.mean, n_resamples=300).params["low"]
assert first == second
def test_bootstrap_ci_rejects_unknown_method(rng):
from mcm_toolkit.stats import bootstrap_ci
with pytest.raises(ValueError, match="bogus"):
bootstrap_ci(rng.normal(size=50), np.mean, method="bogus", n_resamples=10)
with pytest.raises(NotImplementedError):
bootstrap_ci(rng.normal(size=50), np.mean, method="bca", n_resamples=10)
def test_confidence_interval_matches_scipy(rng):
from scipy import stats
from mcm_toolkit.stats import confidence_interval
x = rng.normal(5, 1, size=50)
lo, hi = confidence_interval(x, confidence=0.95, method="t")
ref = stats.t.interval(0.95, len(x) - 1, loc=x.mean(), scale=stats.sem(x))
assert np.allclose([lo, hi], ref, rtol=1e-6)
def test_confidence_interval_normal_matches_z_formula(rng):
"""normal 走 z 解析式:`mean ± z·s/√n`,s 用**样本**标准差(ddof=1)。"""
from scipy import stats as sps
from mcm_toolkit.stats import confidence_interval
x = rng.normal(8.5, 1, size=200)
lo, hi = confidence_interval(x, confidence=0.95, method="normal")
half = float(sps.norm.ppf(0.975)) * x.std(ddof=1) / np.sqrt(len(x))
assert lo == pytest.approx(x.mean() - half)
assert hi == pytest.approx(x.mean() + half)
def test_confidence_interval_mean_is_not_clamped_to_unit_range(rng):
"""均值区间不该被夹到 [0, 1]:平均身高 170cm 的下界不可能是 0。"""
from mcm_toolkit.stats import confidence_interval
x = rng.normal(170, 8, size=50)
lo, hi = confidence_interval(x, method="t")
assert lo > 100.0
assert lo < hi
def test_confidence_interval_proportion_stays_in_unit_interval():
"""比例的区间必须落在 [0, 1] 里 —— 只有这一种 method 需要夹。"""
from mcm_toolkit.stats import confidence_interval
y = [0.0, 1.0, 1.0, 1.0, 0.0, 1.0]
lo, hi = confidence_interval(y, method="proportion")
assert 0.0 <= lo < hi <= 1.0
def test_confidence_interval_accepts_documented_containers():
"""docstring 的 Examples 传 list、L0 用例传 ndarray,两边都得收。"""
from mcm_toolkit.stats import confidence_interval
data = [1.0, 2.0, 3.0, 4.0, 5.0]
expected = confidence_interval(data)
assert confidence_interval(pd.Series(data)) == pytest.approx(expected)
assert confidence_interval(np.asarray(data)) == pytest.approx(expected)
assert confidence_interval(pd.DataFrame({"x": data})) == pytest.approx(expected)
def test_confidence_interval_rejects_bad_input(rng):
from mcm_toolkit.stats import confidence_interval
x = rng.normal(size=20)
with pytest.raises(TypeError):
confidence_interval("abc")
with pytest.raises(ValueError):
confidence_interval(x, method="bogus")
with pytest.raises(ValueError):
confidence_interval(x, confidence=1.5)
with pytest.raises(ValueError):
confidence_interval(x, confidence=0.0)
with pytest.raises(ValueError):
confidence_interval(pd.DataFrame({"city": ["X", "Y"]}))
with pytest.raises(ValueError):
confidence_interval([1.0])
# ------------------------------------------------------ stats: t_test
def test_t_test_one_sample_matches_analytic_formula(rng):
"""单样本:`t = (mean - value) / (s/√n)`,`df = n - 1`,`p = 2·P(T > |t|)`。"""
from scipy import stats as sps
from mcm_toolkit.stats import t_test
x = rng.normal(5, 1, size=30)
r = t_test(x, value=5.0)
t_stat = (x.mean() - 5.0) / (x.std(ddof=1) / np.sqrt(len(x)))
assert r["statistic"] == pytest.approx(t_stat)
assert r["pvalue"] == pytest.approx(float(2 * sps.t.sf(abs(t_stat), len(x) - 1)))
assert r["df"] == pytest.approx(29.0)
assert r["significant"] == (r["pvalue"] < 0.05)
assert isinstance(r["conclusion"], str) and r["conclusion"]
def test_t_test_two_sample_welch_df_matches_formula(rng):
"""默认 Welch:`df` 用 Welch–Satterthwaite 公式解析对照。"""
from mcm_toolkit.stats import t_test
a = rng.normal(0, 1, size=25)
b = rng.normal(1, 4, size=35)
r = t_test(a, b)
v1, v2 = a.var(ddof=1), b.var(ddof=1)
n1, n2 = len(a), len(b)
expected_df = (v1 / n1 + v2 / n2) ** 2 / ((v1 / n1) ** 2 / (n1 - 1) + (v2 / n2) ** 2 / (n2 - 1))
assert r["df"] == pytest.approx(expected_df)
assert r["pvalue"] < 0.05
def test_t_test_equal_var_uses_pooled_df(rng):
from mcm_toolkit.stats import t_test
a = rng.normal(0, 1, size=25)
b = rng.normal(1, 1, size=35)
assert t_test(a, b, equal_var=True)["df"] == pytest.approx(58.0)
def test_t_test_paired_reports_key_set_and_df(rng):
from mcm_toolkit.stats import t_test
a = rng.normal(size=20)
r = t_test(a, a + rng.normal(0, 0.1, 20), paired=True)
assert set(r) == {"statistic", "pvalue", "df", "significant", "conclusion"}
assert r["df"] == pytest.approx(19.0)
def test_t_test_rejects_bad_input(rng):
from mcm_toolkit.stats import t_test
a = rng.normal(size=20)
with pytest.raises(ValueError):
t_test(a, alpha=1.5)
with pytest.raises(ValueError):
t_test([1.0])
with pytest.raises(ValueError):
t_test(a, rng.normal(size=19), paired=True)
with pytest.raises(ValueError):
t_test(pd.DataFrame({"city": ["X", "Y"]}))
with pytest.raises(TypeError):
t_test("abc")
def test_correlation_matrix_diagonal_is_one(rng):
from mcm_toolkit.stats import correlation_matrix
x = pd.DataFrame(rng.normal(size=(200, 4)), columns=["a", "b", "c", "d"])
c = correlation_matrix(x)
assert np.allclose(np.diag(np.asarray(c)), 1.0)
def test_correlation_matrix_is_numeric_and_symmetric(rng):
"""方案 A:返回**数值**矩阵(不是带星标的字符串表),能直接参与后续计算。"""
from mcm_toolkit.stats import correlation_matrix
x = pd.DataFrame(rng.normal(size=(200, 3)), columns=["a", "b", "c"])
c = correlation_matrix(x)
assert all(np.issubdtype(dtype, np.floating) for dtype in c.dtypes)
assert np.allclose(np.asarray(c), np.asarray(x.corr(method="pearson", min_periods=1)))
assert np.allclose(np.asarray(c), np.asarray(c.T))
def test_correlation_matrix_runs_on_multicolumn_frame(rng):
"""回归:`if numeric_cols == 0` 拿 Index 跟 0 比,多列数值表当场 ValueError 崩掉。"""
from mcm_toolkit.stats import correlation_matrix
x = pd.DataFrame(rng.normal(size=(50, 3)), columns=["a", "b", "c"])
out = correlation_matrix(x)
assert out.shape == (3, 3)
assert list(out.columns) == ["a", "b", "c"]
assert list(out.index) == ["a", "b", "c"]
def test_correlation_matrix_skips_non_numeric_columns(rng):
from mcm_toolkit.stats import correlation_matrix
x = pd.DataFrame({"a": rng.normal(size=30), "b": rng.normal(size=30), "city": ["X"] * 30})
assert list(correlation_matrix(x).columns) == ["a", "b"]
def test_correlation_matrix_rejects_bad_input():
from mcm_toolkit.stats import correlation_matrix
with pytest.raises(TypeError):
correlation_matrix([[1.0, 2.0]])
with pytest.raises(ValueError):
correlation_matrix(pd.DataFrame({"city": ["X", "Y"]}))
with pytest.raises(ValueError):
correlation_matrix(pd.DataFrame({"a": [1.0, 2.0]}), method="bogus") # type: ignore[arg-type]
def test_correlation_pvalues_matches_analytic_formula(rng):
from mcm_toolkit.stats import correlation_matrix, correlation_pvalues
x = pd.DataFrame(rng.normal(size=(80, 3)), columns=["a", "b", "c"])
corr = correlation_matrix(x)
p = correlation_pvalues(x)
for i in ["a", "b", "c"]:
for j in ["a", "b", "c"]:
if i == j:
continue
assert p.loc[i, j] == pytest.approx(_pearson_p_from_r(corr.loc[i, j], 80))
def test_correlation_pvalues_diagonal_is_nan(rng):
"""对角线写 0 会被下游读成"极显著";"自己和自己相关"没有 p 值,必须是 NaN。"""
from mcm_toolkit.stats import correlation_pvalues
p = correlation_pvalues(pd.DataFrame(rng.normal(size=(50, 2)), columns=["a", "b"]))
assert np.isnan(p.loc["a", "a"])
assert np.isnan(p.loc["b", "b"])
def test_correlation_pvalues_uses_the_same_observations_as_r(rng):
"""含缺失时 p 要和 r 用**同一批**观测:成对删缺失,而不是整行删、也不是干脆不删。"""
from mcm_toolkit.stats import correlation_pvalues
a = rng.normal(size=40)
b = rng.normal(size=40)
a[[3, 7, 11]] = np.nan
x = pd.DataFrame({"a": a, "b": b})
pair = x.dropna()
r = float(np.corrcoef(pair["a"], pair["b"])[0, 1])
assert correlation_pvalues(x).loc["a", "b"] == pytest.approx(_pearson_p_from_r(r, len(pair)))
def test_correlation_pvalues_respects_min_periods(rng):
from mcm_toolkit.stats import correlation_pvalues
a = rng.normal(size=10)
a[:5] = np.nan
x = pd.DataFrame({"a": a, "b": rng.normal(size=10)})
assert np.isnan(correlation_pvalues(x, min_periods=6).loc["a", "b"])
assert np.isfinite(correlation_pvalues(x, min_periods=5).loc["a", "b"])
def test_correlation_table_diagonal_has_no_stars():
"""对角线是"自己和自己",不该有显著性星标;完全负相关则必然是 ***。"""
from mcm_toolkit.stats import correlation_table
x = pd.DataFrame({"a": np.arange(30.0), "b": np.arange(30.0) * -2.0})
table = correlation_table(x)
assert table.loc["a", "a"] == "1.000"
assert table.loc["a", "b"] == "-1.000***"
def test_correlation_table_matches_the_numeric_outputs(rng):
from mcm_toolkit.stats import correlation_matrix, correlation_pvalues, correlation_table
x = pd.DataFrame(rng.normal(size=(60, 3)), columns=["a", "b", "c"])
corr = correlation_matrix(x)
p = correlation_pvalues(x)
table = correlation_table(x)
assert list(table.columns) == list(corr.columns)
assert table.loc["a", "a"] == "1.000"
assert np.isnan(p.loc["a", "a"])
pv = p.loc["a", "b"]
stars = "***" if pv < 0.001 else "**" if pv < 0.01 else "*" if pv < 0.05 else ""
value = corr.loc["a", "b"]
assert table.loc["a", "b"] == f"{value:.3f}" + stars
# ------------------------------------------------------ stats: describe
def test_describe_reports_expected_keys(rng):
from mcm_toolkit.stats import describe
table = describe(rng.normal(size=(100, 3)))
for key in ["mean", "std", "min", "max", "median"]:
assert key in table.index
def test_describe_rows_are_metrics_and_columns_are_variables():
"""方向约定:行 = 指标,列 = 变量。多转置一次就反了。"""
from mcm_toolkit.stats import describe
df = pd.DataFrame({"a": [1.0, 2.0, 3.0], "b": [10.0, 20.0, 30.0]})
table = describe(df)
assert list(table.columns) == ["a", "b"]
assert table.loc["mean", "a"] == pytest.approx(2.0)
assert table.loc["max", "b"] == pytest.approx(30.0)
assert table.loc["median", "b"] == pytest.approx(20.0)
def test_describe_missing_and_shape_rows_are_not_nan():
"""曾经的静默 bug:转置后 `.loc[指标名] = <按变量索引的 Series>` 对不上列,
整行会被填成 NaN —— 不报错,但数据全丢。"""
from mcm_toolkit.stats import describe
df = pd.DataFrame({"a": [1.0, np.nan, 3.0, 4.0], "b": [1.0, 2.0, 3.0, 4.0]})
table = describe(df)
assert table.loc["missing", "a"] == pytest.approx(1.0)
assert table.loc["missing", "b"] == pytest.approx(0.0)
assert table.loc["missing_ratio", "a"] == pytest.approx(0.25)
assert table.loc["missing_ratio", "b"] == pytest.approx(0.0)
assert table.loc["skewness", "b"] == pytest.approx(0.0, abs=1e-12)
extra = table.loc[["missing", "missing_ratio", "skewness", "kurtosis"]]
assert not bool(extra.isna().all(axis=None))
def test_describe_skips_non_numeric_columns():
from mcm_toolkit.stats import describe
df = pd.DataFrame({"a": [1.0, 2.0], "city": ["X", "Y"]})
table = describe(df)
assert list(table.columns) == ["a"]
def test_describe_accepts_ndarray_list_and_series(rng):
"""兄弟函数都收 ndarray / list,describe 没理由只收 DataFrame。"""
from mcm_toolkit.stats import describe
assert "mean" in describe(rng.normal(size=(50, 2))).index
assert "mean" in describe([[1.0, 2.0], [3.0, 4.0]]).index
assert "mean" in describe(pd.Series([1.0, 2.0, 3.0])).index
def test_describe_weighted_rows_match_manual_formula():
from mcm_toolkit.stats import describe
df = pd.DataFrame({"a": [1.0, 2.0, 3.0]})
w = np.array([1.0, 1.0, 2.0])
table = describe(df, weights=w)
expect_mean = np.average(df["a"], weights=w)
expect_std = np.sqrt(np.average((df["a"] - expect_mean) ** 2, weights=w))
assert table.loc["weighted_mean", "a"] == pytest.approx(expect_mean)
assert table.loc["weighted_std", "a"] == pytest.approx(expect_std)
@pytest.mark.parametrize(
("weights", "exc"),
[
(np.ones(2), ValueError),
(np.zeros(3), ValueError),
(np.array([1.0, -1.0, 1.0]), ValueError),
(np.array([1.0, np.nan, 1.0]), ValueError),
("abc", TypeError),
],
)
def test_describe_rejects_bad_weights(weights, exc):
from mcm_toolkit.stats import describe
with pytest.raises(exc):
describe(pd.DataFrame({"a": [1.0, 2.0, 3.0]}), weights=weights)
def test_describe_rejects_unsupported_type_and_all_text_frame():
from mcm_toolkit.stats import describe
with pytest.raises(TypeError):
describe({"a": [1.0]})
with pytest.raises(ValueError):
describe(pd.DataFrame({"city": ["X", "Y"]}))
# ------------------------------------------------------ stats: zscore
def test_zscore_matches_manual_formula():
from mcm_toolkit.stats import zscore
x = np.array([1.0, 2.0, 3.0, 4.0])
assert np.allclose(np.asarray(zscore(x)), (x - x.mean()) / x.std())
def test_zscore_is_per_column_and_keeps_frame(rng):
from mcm_toolkit.stats import zscore
df = pd.DataFrame({"a": rng.normal(size=200), "b": rng.normal(5, 3, size=200)})
out = zscore(df)
assert isinstance(out, pd.DataFrame)
assert list(out.columns) == ["a", "b"]
assert np.allclose(np.asarray(out).mean(axis=0), 0.0, atol=1e-12)
assert np.allclose(np.asarray(out).std(axis=0), 1.0, atol=1e-12)
def test_zscore_constant_column_stays_zero():
from mcm_toolkit.stats import zscore
assert np.allclose(np.asarray(zscore([2.0, 2.0, 2.0])), 0.0)
def test_zscore_series_keeps_index_and_name(rng):
"""Series 进 Series 出,索引和 name 都要带回来 —— 否则管道里 concat 会丢列名。"""
from mcm_toolkit.stats import zscore
s = pd.Series(rng.normal(size=50), index=[f"r{i}" for i in range(50)], name="price")
out = zscore(s)
assert isinstance(out, pd.Series)
assert out.name == "price"
assert list(out.index) == list(s.index)
def test_zscore_rejects_bad_type_and_all_text_frame():
from mcm_toolkit.stats import zscore
with pytest.raises(TypeError):
zscore("abc")
with pytest.raises(ValueError):
zscore(pd.DataFrame({"city": ["X"]}))
# ------------------------------------------- stats: coefficient_of_variation
def test_coefficient_of_variation_matches_manual_formula():
from mcm_toolkit.stats import coefficient_of_variation
x = np.array([1.0, 2.0, 3.0, 4.0])
assert coefficient_of_variation(x) == pytest.approx(x.std() / x.mean())
def test_coefficient_of_variation_accepts_single_column_frame():
from mcm_toolkit.stats import coefficient_of_variation
df = pd.DataFrame({"a": [1.0, 2.0, 3.0]})
single = coefficient_of_variation([1.0, 2.0, 3.0])
assert coefficient_of_variation(df) == pytest.approx(single)
def test_coefficient_of_variation_uses_absolute_mean():
"""均值为负时 CV 仍是**正数**:符号不该被读成"波动更小"。"""
from mcm_toolkit.stats import coefficient_of_variation
x = np.array([-1.0, -2.0, -3.0, -4.0])
assert coefficient_of_variation(x) == pytest.approx(x.std() / abs(x.mean()))
assert coefficient_of_variation(x) > 0
def test_coefficient_of_variation_zero_mean_is_value_error():
"""均值为 0 是**取值**问题不是类型问题:必须是 ValueError 而不是 TypeError。"""
from mcm_toolkit.stats import coefficient_of_variation
with pytest.raises(ValueError):
coefficient_of_variation([-1.0, 1.0])
def test_coefficient_of_variation_rejects_empty_2d_and_bad_type():
from mcm_toolkit.stats import coefficient_of_variation
with pytest.raises(ValueError):
coefficient_of_variation([])
with pytest.raises(ValueError):
coefficient_of_variation([[1.0, 2.0], [3.0, 4.0]])
with pytest.raises(ValueError):
coefficient_of_variation(pd.DataFrame({"a": [1.0], "b": [2.0]}))
with pytest.raises(TypeError):
coefficient_of_variation(5.0)
+143
View File
@@ -0,0 +1,143 @@
"""viz 包测试。
绘图测试不比对像素——只验证"能画出来、能存成文件、尺寸符合美赛要求"。
未实现的函数按决策 26 **逐条** skip,不用模块级 `pytestmark`。
"""
from __future__ import annotations
import matplotlib
matplotlib.use("Agg")
import pytest
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L0/L1"
@pytest.fixture
def fig():
"""最小 Figure;测试结束统一关闭,避免 matplotlib 的"图开太多"告警。"""
import matplotlib.pyplot as plt
figure, _ = plt.subplots(figsize=(2, 2))
yield figure
plt.close(figure)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
@pytest.mark.parametrize("kind", ["line", "scatter", "hist", "box", "bar"])
def test_plot_kinds_produce_figure(kind, rng):
from mcm_toolkit.viz import plot
x = rng.normal(size=100)
figure, ax = plot(x, x, kind=kind)
assert figure is not None and ax is not None
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_mcm_style_sets_300dpi():
from mcm_toolkit.viz import mcm_style
mcm_style()
assert matplotlib.rcParams["figure.dpi"] == 300.0
assert matplotlib.rcParams["font.size"] >= 8.0
def test_save_figure_writes_png_and_pdf(fig, tmp_path):
from mcm_toolkit.viz import save_figure
paths = save_figure(fig, tmp_path / "fig1", formats=("png", "pdf"))
assert [p.suffix for p in paths] == [".png", ".pdf"]
assert all(p.exists() and p.stat().st_size > 0 for p in paths)
def test_save_figure_strips_an_image_suffix_from_path(fig, tmp_path):
"""`fig1.png` 当主干用:不能写出 `fig1.png.png`。"""
from mcm_toolkit.viz import save_figure
paths = save_figure(fig, tmp_path / "fig1.png", formats=("pdf",))
assert paths == [tmp_path / "fig1.pdf"]
assert paths[0].exists()
def test_save_figure_creates_parent_directories(fig, tmp_path):
from mcm_toolkit.viz import save_figure
target = tmp_path / "nested" / "deeper" / "fig1"
paths = save_figure(fig, target, formats=("png",))
assert paths[0] == tmp_path / "nested" / "deeper" / "fig1.png"
assert paths[0].exists()
def test_save_figure_normalizes_format_case_and_keeps_order(fig, tmp_path):
from mcm_toolkit.viz import save_figure
paths = save_figure(fig, tmp_path / "fig1", formats=("PDF", "png"))
assert [p.suffix for p in paths] == [".pdf", ".png"]
assert all(p.exists() for p in paths)
def test_save_figure_rejects_a_single_string_as_formats(fig, tmp_path):
from mcm_toolkit.viz import save_figure
with pytest.raises(TypeError, match="不要传单个字符串"):
save_figure(fig, tmp_path / "fig1", formats="png") # type: ignore[arg-type]
def test_save_figure_rejects_bad_formats(fig, tmp_path):
from mcm_toolkit.viz import save_figure
with pytest.raises(ValueError, match="不能为空"):
save_figure(fig, tmp_path / "fig1", formats=())
with pytest.raises(ValueError, match="不支持"):
save_figure(fig, tmp_path / "fig1", formats=("gif",))
with pytest.raises(ValueError, match="重复"):
save_figure(fig, tmp_path / "fig1", formats=("png", "PNG"))
def test_save_figure_validates_before_creating_directories(fig, tmp_path):
"""格式错时不能先建目录留下副作用。"""
from mcm_toolkit.viz import save_figure
nested = tmp_path / "should_not_exist" / "fig1"
with pytest.raises(ValueError, match="不支持"):
save_figure(fig, nested, formats=("gif",))
assert not nested.parent.exists()
def test_save_figure_rejects_bad_render_options(fig, tmp_path):
from mcm_toolkit.viz import save_figure
with pytest.raises(TypeError, match="dpi"):
save_figure(fig, tmp_path / "fig1", dpi=300.0) # type: ignore[arg-type]
with pytest.raises(TypeError, match="dpi"):
save_figure(fig, tmp_path / "fig1", dpi=True)
with pytest.raises(ValueError, match="dpi"):
save_figure(fig, tmp_path / "fig1", dpi=0)
with pytest.raises(TypeError, match="bbox_inches"):
save_figure(fig, tmp_path / "fig1", bbox_inches=3) # type: ignore[arg-type]
with pytest.raises(ValueError, match="bbox_inches"):
save_figure(fig, tmp_path / "fig1", bbox_inches="")
with pytest.raises(TypeError, match="transparent"):
save_figure(fig, tmp_path / "fig1", transparent="yes") # type: ignore[arg-type]
def test_save_figure_rejects_non_figure(tmp_path):
from mcm_toolkit.viz import save_figure
with pytest.raises(TypeError, match="Figure"):
save_figure(object(), tmp_path / "fig1") # type: ignore[arg-type]
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_save_table_writes_latex_with_caption(tmp_path):
import pandas as pd
from mcm_toolkit.viz import save_table
df = pd.DataFrame({"rmse": [0.31, 0.28]}, index=["linear", "rf"])
paths = save_table(df, tmp_path / "tab1", caption="模型对比", label="tab:compare")
tex = next(p for p in paths if p.suffix == ".tex")
content = tex.read_text(encoding="utf-8")
assert "模型对比" in content and "tab:compare" in content