Files
MCM-ICM_Toolkit/docs/02-architecture.md
T
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

11 KiB
Raw Blame History

架构设计

上游依据: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 个包)

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(唯一结果对象)

@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 随机数

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 模型协议

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:

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。
  • 每条断言都要能说清"错了说明什么"。