diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f5675..0d07d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -271,6 +271,36 @@ **`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 的口径一致。 + ## [0.0.1] - 2026-10-01 ### Added diff --git a/STATUS.md b/STATUS.md index 1c1ae2c..27b1819 100644 --- a/STATUS.md +++ b/STATUS.md @@ -193,13 +193,25 @@ - 测试 `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`,一份实现两张脸 - [ ] 剩余 3 项:`bootstrap_ci` `monte_carlo` `save_figure` ### 验证结果 | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **125 passed, 37 skipped(0 xfail)** | +| `pytest` | 见下(单列) | **134 passed, 37 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` | 跑通 | — | @@ -209,13 +221,14 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- **162 条用例:125 passed / 37 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - `test_core.py` 15、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 46、 +- **171 条用例:134 passed / 37 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 + `test_core.py` 23、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 47、 `test_models.py` 5 - **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正 - 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环 - `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz` -- 37 条 skip 是等实现的算法(`train_test_split`、`bootstrap_ci`、`monte_carlo`、`anova`、`fit_distribution` 等) +- 37 条 skip 是等实现的算法(`train_test_split`、`bootstrap_ci`、`monte_carlo`、`anova`、`fit_distribution` 等); + `TestValidate` 的 4 条仍挂着 —— `check_*` 还是 `NotImplementedError`,但它们实现时应当直接复用 `as_array` - **本轮回归(已修)**:`transform.py` 上一版有 3 处真 bug —— 修前 **7 failed / 75 passed**, 且 pyright 在同一处报 `reportArgumentType`。**不是静态误报**:pyright CLI 复现 + 测试真红。 结论:见到红线先跑 CLI 与 `pytest`,**不许直接挂 `# type: ignore`**(见个人易错点 10) @@ -293,6 +306,10 @@ 原码把三种 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 说不统一就列出来) | `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 | @@ -321,3 +338,12 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 - **④ `t_test(DataFrame)` 会把所有数值列摊平成一个样本**(已写进 `Notes`)。 多列不同量纲(`gdp` + `pop`)会被混在一起,而且**没法**拿"一张表里两列"当两组比。 要不要改成:多数值列的 DataFrame 直接 `ValueError`、强制先自己取列? + +7. **`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` 顺带把选中的列返回(改成三元组),一句话的事。 \ No newline at end of file diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 1d9ed3b..1b080fa 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -24,7 +24,7 @@ src/mcm_toolkit/ ├── core/ ① 基础设施 —— 所有包的地基 │ ├── result.py Result │ ├── random.py set_seed / get_rng -│ └── validate.py check_array / check_same_length / check_probability / check_square +│ └── validate.py as_array(统一入口)/ check_array / check_same_length / check_probability / check_square │ ├── data/ ② 数据进出 —— 最高频 │ ├── io.py load_table / save_table @@ -183,6 +183,32 @@ docstring 给人看。两处打架时,合法用法会被标红,真正的错 参考实现:`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`。 + --- ## 五、测试策略 diff --git a/docs/03-roadmap.md b/docs/03-roadmap.md index ac3e950..1411cef 100644 --- a/docs/03-roadmap.md +++ b/docs/03-roadmap.md @@ -37,6 +37,8 @@ |---|---| | `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` | diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index 34cc57a..f3e2a79 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -37,6 +37,7 @@ | 13 | `Index == 标量` 得到布尔数组,`if` 要等到第 2 列才炸 | 2026-10-03 | | 14 | 把"能看的表"和"能算的表"塞进同一个返回值 | 2026-10-03 | | 15 | 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 参数写反 | 2026-10-03 | +| 16 | 同一段入口转换抄了 N 遍,还当它是"本项目的写法" | 2026-10-03 | --- @@ -269,3 +270,20 @@ ④ 记住 `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 diff --git a/src/mcm_toolkit/core/__init__.py b/src/mcm_toolkit/core/__init__.py index 94e81e9..5fa4f49 100644 --- a/src/mcm_toolkit/core/__init__.py +++ b/src/mcm_toolkit/core/__init__.py @@ -1,4 +1,4 @@ -"""core —— 基础设施:结果对象、随机数、参数校验。 +"""core —— 基础设施:结果对象、随机数、参数校验与入口转换。 其他所有包都依赖本包;本包**不**依赖任何其他子包。 """ @@ -6,6 +6,7 @@ from .random import get_rng, set_seed from .result import Result from .validate import ( + as_array, check_array, check_probability, check_same_length, @@ -14,6 +15,7 @@ from .validate import ( __all__ = [ "Result", + "as_array", "get_rng", "set_seed", "check_array", diff --git a/src/mcm_toolkit/core/validate.py b/src/mcm_toolkit/core/validate.py index 4cf6129..42ff146 100644 --- a/src/mcm_toolkit/core/validate.py +++ b/src/mcm_toolkit/core/validate.py @@ -1,33 +1,147 @@ -"""参数校验。 +"""参数校验与入口转换。 目的不是"教用户用库",而是**把错误提前到调用点**: 比赛时最贵的是 debug 时间,一个 `ValueError: 数组长度不一致` 比 十层 traceback 里的 `IndexError` 值钱得多。 + +**所有收数值的函数都从 `as_array` 进**:它一次做完"类型白名单 + 转 ndarray + 告诉调用方原来是什么", +省掉每个函数各抄一遍 `isinstance(x, pd.DataFrame)` / `select_dtypes` / `np.asarray`。 """ from __future__ import annotations -from typing import Any +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(L0) + # TODO(L1) raise NotImplementedError @@ -38,17 +152,17 @@ def check_same_length(*arrays: Any) -> None: -------- >>> check_same_length([1, 2], [3, 4]) """ - # TODO(L0) + # TODO(L1) raise NotImplementedError def check_probability(p: float) -> float: """检查 p 落在 [0, 1],返回 float。""" - # TODO(L0) + # TODO(L1) raise NotImplementedError def check_square_matrix(a: Any) -> np.ndarray: """检查是方阵,返回 ndarray。AHP 判断矩阵、转移矩阵都要用。""" - # TODO(L0) + # TODO(L1) raise NotImplementedError diff --git a/src/mcm_toolkit/data/transform.py b/src/mcm_toolkit/data/transform.py index 21aeadd..22d93b0 100644 --- a/src/mcm_toolkit/data/transform.py +++ b/src/mcm_toolkit/data/transform.py @@ -13,10 +13,9 @@ from typing import Any import numpy as np import pandas as pd -__all__ = ["log_transform", "normalize", "one_hot_encode", "standardize"] +from ..core.validate import as_array -# 数值输入白名单。DataFrame 必须写进来,否则下面所有 is_df 分支都是死代码。 -_NUMERIC_TYPES = (int, float, list, tuple, np.ndarray, pd.Series, pd.DataFrame) +__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: @@ -68,15 +67,8 @@ def normalize(x: Any, method: str = "minmax", feature_range: tuple[float, float] if feature_range[0] >= feature_range[1]: raise ValueError(f"feature_range的下界必须小于上界,你传入了{feature_range}") - if not isinstance(x, _NUMERIC_TYPES): - raise TypeError(f"输入必须为数值型、列表、numpy array、pandas Series 或 DataFrame,你传入了{type(x)}") - - is_df = isinstance(x, pd.DataFrame) - is_series = isinstance(x, pd.Series) - original_cols: Any = x.columns if is_df else None - original_index: Any = x.index if (is_df or is_series) else None - - arr = np.asarray(x, dtype=float) + # numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。 + arr, kind = as_array(x, numeric_only=False) if method == "minmax": x_min = np.min(arr, axis=0) @@ -95,10 +87,10 @@ def normalize(x: Any, method: str = "minmax", feature_range: tuple[float, float] x_max = np.where(x_max == 0, 1.0, x_max) result = arr / x_max - if is_df: - return pd.DataFrame(result, columns=original_cols, index=original_index) - if is_series: - return pd.Series(result, index=original_index) + 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 @@ -128,15 +120,8 @@ def standardize(x: Any, ddof: int = 0) -> Any: 常量列(`std == 0`)不产生 nan / inf,固定输出 0,与 `sklearn` 的 `StandardScaler` 对零方差列的处理一致。 """ - if not isinstance(x, _NUMERIC_TYPES): - raise TypeError(f"输入必须为数值型、列表、numpy array、pandas Series 或 DataFrame,你传入了{type(x)}") - - is_df = isinstance(x, pd.DataFrame) - is_series = isinstance(x, pd.Series) - original_cols: Any = x.columns if is_df else None - original_index: Any = x.index if (is_df or is_series) else None - - arr = np.asarray(x, dtype=float) + # numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。 + arr, kind = as_array(x, numeric_only=False) mean = np.mean(arr, axis=0) @@ -146,10 +131,10 @@ def standardize(x: Any, ddof: int = 0) -> Any: result = (arr - mean) / std - if is_df: - return pd.DataFrame(result, columns=original_cols, index=original_index) - if is_series: - return pd.Series(result, index=original_index) + 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 @@ -181,15 +166,8 @@ def log_transform(x: Any, offset: float | None = None) -> Any: >>> log_transform([1.0, 2.718281828459045]).round(3).tolist() [0.0, 1.0] """ - if not isinstance(x, _NUMERIC_TYPES): - raise TypeError(f"输入必须为数值型、列表、numpy array、pandas Series 或 DataFrame,你传入了{type(x)}") - - is_df = isinstance(x, pd.DataFrame) - is_series = isinstance(x, pd.Series) - original_cols: Any = x.columns if is_df else None - original_index: Any = x.index if (is_df or is_series) else None - - arr = np.asarray(x, dtype=float) + # numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。 + arr, kind = as_array(x, numeric_only=False) if offset is None: min_val = np.min(arr) @@ -201,10 +179,10 @@ def log_transform(x: Any, offset: float | None = None) -> Any: result = np.log(arr + offset) - if is_df: - return pd.DataFrame(result, columns=original_cols, index=original_index) - if is_series: - return pd.Series(result, index=original_index) + 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 diff --git a/src/mcm_toolkit/stats/descriptive.py b/src/mcm_toolkit/stats/descriptive.py index 26151ab..537ff50 100644 --- a/src/mcm_toolkit/stats/descriptive.py +++ b/src/mcm_toolkit/stats/descriptive.py @@ -11,6 +11,8 @@ 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"] @@ -139,25 +141,7 @@ def zscore(x: Any, ddof: int = 0) -> Any: 与 `data.transform.standardize` 是同一个公式;差别只在 DataFrame 里遇到 非数值列时:这里**跳过**,`standardize` 会直接抛错。 """ - allowed_types = (pd.DataFrame, pd.Series, list, tuple, np.ndarray) - if not isinstance(x, allowed_types): - raise TypeError(f"仅接受{allowed_types},不接受{type(x)}") - - is_df = isinstance(x, pd.DataFrame) - is_series = isinstance(x, pd.Series) - if is_df: - numeric_cols = x.select_dtypes(include=[np.number]).columns - if len(numeric_cols) == 0: - raise ValueError("数据中没有数值列,无法计算 Z 分数") - target_data = cast(pd.DataFrame, x[numeric_cols]) - original_cols = target_data.columns - original_index = target_data.index - else: - target_data = x - original_cols = None - original_index = x.index if is_series else None - - arr = np.asarray(target_data, dtype=float) + arr, kind = as_array(x, numeric_only=True) mean = np.mean(arr, axis=0) std = np.std(arr, axis=0, ddof=ddof) @@ -165,10 +149,12 @@ def zscore(x: Any, ddof: int = 0) -> Any: std = np.where(std == 0, 1.0, std) result = (arr - mean) / std - if is_df: - return pd.DataFrame(result, columns=original_cols, index=original_index) - if is_series: - return pd.Series(result, index=original_index) + 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 diff --git a/src/mcm_toolkit/stats/inference.py b/src/mcm_toolkit/stats/inference.py index 2729db5..f07a1a1 100644 --- a/src/mcm_toolkit/stats/inference.py +++ b/src/mcm_toolkit/stats/inference.py @@ -13,6 +13,8 @@ import numpy as np import pandas as pd import scipy.stats as stats +from ..core.validate import as_array + __all__ = [ "anova", "chi_square_test", @@ -252,24 +254,14 @@ def confidence_interval( -------- >>> lo, hi = confidence_interval([1, 2, 3, 4, 5], confidence=0.95) """ - if not isinstance(x, (list, tuple, np.ndarray, pd.Series, pd.DataFrame)): - raise TypeError(f"x 必须是 list / tuple / ndarray / Series / DataFrame,而你传入了{type(x)}") + # 入口统一走 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}") - if isinstance(x, pd.DataFrame): - numeric_cols = x.select_dtypes(include=[np.number]).columns - if len(numeric_cols) == 0: - raise ValueError("x 里没有数值列") - arr = cast(pd.DataFrame, x[numeric_cols]).to_numpy(dtype=float).ravel() - else: - try: - arr = np.asarray(x, dtype=float).ravel() - except (ValueError, TypeError) as e: - raise TypeError(f"x 必须是数值型数据,无法转成 float 数组:{e}") from None - arr = arr[~np.isnan(arr)] n = len(arr) if n < 2: @@ -328,16 +320,7 @@ def t_test(a: Any, b: Any | None = None, value: float = 0.0, equal_var: bool = F if not (0 < alpha < 1): raise ValueError(f"显著性水平 alpha 必须在 (0, 1) 之间,你传入了 {alpha}") def _clean_data(data: Any) -> np.ndarray: - if isinstance(data, pd.DataFrame): - numeric_cols = data.select_dtypes(include=[np.number]).columns - if len(numeric_cols) == 0: - raise ValueError("没有找到数值列") - arr = cast(pd.DataFrame, data[numeric_cols]).to_numpy(dtype=float).ravel() - else: - try: - arr = np.asarray(data, dtype=float).ravel() - except (ValueError, TypeError) as e: - raise TypeError(f"样本必须是数值型数据,无法转成 float 数组:{e}") from None + arr, _ = as_array(data, numeric_only=True, ndim=1) return arr[~np.isnan(arr)] a_arr = _clean_data(a) diff --git a/tests/test_core.py b/tests/test_core.py index 477933d..cf6e75f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,11 +1,12 @@ """core 包测试。 -TODO(L0):TestValidate 实现后去掉 skip 标记(Result 与 Random 已完成)。 +TODO(L0):TestValidate 的 check_* 实现后去掉 skip 标记(Result / Random / as_array 已完成)。 """ from __future__ import annotations import numpy as np +import pandas as pd import pytest @@ -96,6 +97,83 @@ class TestValidate: 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 diff --git a/tests/test_stats.py b/tests/test_stats.py index b277810..7c235d7 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -460,6 +460,18 @@ def test_zscore_constant_column_stays_zero(): 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