Files
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

230 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构设计
> 上游依据:`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`。
- 每条断言都要能说清"错了说明什么"。