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`。
This commit is contained in:
guzhujushi committed 2026-10-03 19:17:22 +08:00
1 parent f95fbd2726
commit b70a96dd0c
12 files changed
+356 -101

No files matched your search

+30
View File
@@ -271,6 +271,36 @@
**`t_test` 待用户拍板的 4 个口径**(单样本结论文案、`paired` 的 NaN 对齐、`value` 被静默忽略、 **`t_test` 待用户拍板的 4 个口径**(单样本结论文案、`paired` 的 NaN 对齐、`value` 被静默忽略、
`DataFrame` 摊平多列)—— 按决策 39,Codex 只列选项、不代选。 `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 ## [0.0.1] - 2026-10-01
### Added ### Added
+30 -4
View File
@@ -193,13 +193,25 @@
- 测试 `tests/test_stats.py`:**+10 条(36 → 46)**,含把原先挂着 skip 的 `test_confidence_interval_matches_scipy` 转正; - 测试 `tests/test_stats.py`:**+10 条(36 → 46)**,含把原先挂着 skip 的 `test_confidence_interval_matches_scipy` 转正;
对照一律用解析式(z 公式、Welch `df` 公式、`t = (mean − value)/(s/√n)`),不拿实现对自己 对照一律用解析式(z 公式、Welch `df` 公式、`t = (mean − value)/(s/√n)`),不拿实现对自己
- ⚠️ **`t_test` 还有 3 个口径待用户拍板**(Codex 未改,见「下一步」第 6 条) - ⚠️ **`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` - [ ] 剩余 3 项:`bootstrap_ci` `monte_carlo` `save_figure`
### 验证结果 ### 验证结果
| 检查 | venv | conda `mcm-toolkit` | | 检查 | 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 项 | | `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
| `ruff check` | All checks passed | **All checks passed**(0 error) | | `ruff check` | All checks passed | **All checks passed**(0 error) |
| `scripts/doctor.ps1` | 跑通 | — | | `scripts/doctor.ps1` | 跑通 | — |
@@ -209,13 +221,14 @@
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
`docs/04-environment.md` 坑 7) `docs/04-environment.md` 坑 7)
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) - 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、 - **171 条用例:134 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、 `test_core.py` 23、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 47、
`test_models.py` 5 `test_models.py` 5
- **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正 - **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正
- 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环 - 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环
- `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz` - `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**, - **本轮回归(已修)**:`transform.py` 上一版有 3 处真 bug —— 修前 **7 failed / 75 passed**,
且 pyright 在同一处报 `reportArgumentType`。**不是静态误报**:pyright CLI 复现 + 测试真红。 且 pyright 在同一处报 `reportArgumentType`。**不是静态误报**:pyright CLI 复现 + 测试真红。
结论:见到红线先跑 CLI 与 `pytest`,**不许直接挂 `# type: ignore`**(见个人易错点 10) 结论:见到红线先跑 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` 一行即可 | 原码把三种 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` 成对 | | 43 | scipy 检验结果的**统计量**也走 `_statistic(result: Any) -> float` 助手,与决策 40 的 `_pvalue` 成对 |
`TtestResult` 与 `PearsonRResult` 同源(`_make_tuple_bunch` 运行时拼类),存根里 `.statistic` / `.pvalue` 都没有。用户报的「307 / 308 行报错」就是这两处,**不是真 bug**(探针实测取值正常)。见 `docs/04-environment.md` 坑 10 | `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 | `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 定"非算法事务的默认归口" | 用户 2026-10-03 明确:没考虑到的非法情况"告诉我我自己改"——因为那是设计决策,得由写实现的人形成判断;而 lint / 进度板 / 测试这类机械活再由用户动手只是浪费。这条与决策 25 / 31 配套:25 定"谁写 src",31 定"Codex 可改的 bug 范围",39 定"非算法事务的默认归口" |
docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向,实现里多做的 `.T` 是净负担 —— 且不是"风格不同"那么轻:转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名零交集,pandas **不报错**、把四行整行填成 NaN。方向约定以"能按列名对齐"为准,见易错点 11 | 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`)。 - **④ `t_test(DataFrame)` 会把所有数值列摊平成一个样本**(已写进 `Notes`)。
多列不同量纲(`gdp` + `pop`)会被混在一起,而且**没法**拿"一张表里两列"当两组比。 多列不同量纲(`gdp` + `pop`)会被混在一起,而且**没法**拿"一张表里两列"当两组比。
要不要改成:多数值列的 DataFrame 直接 `ValueError`、强制先自己取列? 要不要改成:多数值列的 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` 顺带把选中的列返回(改成三元组),一句话的事。
+27 -1
View File
@@ -24,7 +24,7 @@ src/mcm_toolkit/
├── core/ ① 基础设施 —— 所有包的地基 ├── core/ ① 基础设施 —— 所有包的地基
│ ├── result.py Result │ ├── result.py Result
│ ├── random.py set_seed / get_rng │ ├── 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/ ② 数据进出 —— 最高频 ├── data/ ② 数据进出 —— 最高频
│ ├── io.py load_table / save_table │ ├── io.py load_table / save_table
@@ -183,6 +183,32 @@ docstring 给人看。两处打架时,合法用法会被标红,真正的错
参考实现:`core/random.py` 的 `get_rng`。 参考实现:`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`。
--- ---
## 五、测试策略 ## 五、测试策略
+2
View File
@@ -37,6 +37,8 @@
|---|---| |---|---|
| `core` | `check_array` `check_same_length` `check_probability` `check_square_matrix` | | `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` | | `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` | | `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` | | `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` | | `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` |
+18
View File
@@ -37,6 +37,7 @@
| 13 | `Index == 标量` 得到布尔数组,`if` 要等到第 2 列才炸 | 2026-10-03 | | 13 | `Index == 标量` 得到布尔数组,`if` 要等到第 2 列才炸 | 2026-10-03 |
| 14 | 把"能看的表"和"能算的表"塞进同一个返回值 | 2026-10-03 | | 14 | 把"能看的表"和"能算的表"塞进同一个返回值 | 2026-10-03 |
| 15 | 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 参数写反 | 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` / `max` **不传播 nan、结果还取决于参数顺序**(`min(0.0, nan)` → `0.0`、
`min(nan, 0.0)` → `nan`)—— "夹一下"就能把 nan 悄悄换成边界值。 `min(nan, 0.0)` → `nan`)—— "夹一下"就能把 nan 悄悄换成边界值。
- 首次:2026-10-03|复发:0 - 首次: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
+3 -1
View File
@@ -1,4 +1,4 @@
"""core —— 基础设施:结果对象、随机数、参数校验。 """core —— 基础设施:结果对象、随机数、参数校验与入口转换。
其他所有包都依赖本包;本包**不**依赖任何其他子包。 其他所有包都依赖本包;本包**不**依赖任何其他子包。
""" """
@@ -6,6 +6,7 @@
from .random import get_rng, set_seed from .random import get_rng, set_seed
from .result import Result from .result import Result
from .validate import ( from .validate import (
as_array,
check_array, check_array,
check_probability, check_probability,
check_same_length, check_same_length,
@@ -14,6 +15,7 @@ from .validate import (
__all__ = [ __all__ = [
"Result", "Result",
"as_array",
"get_rng", "get_rng",
"set_seed", "set_seed",
"check_array", "check_array",
+120 -6
View File
@@ -1,33 +1,147 @@
"""参数校验。 """参数校验与入口转换。
目的不是"教用户用库",而是**把错误提前到调用点**: 目的不是"教用户用库",而是**把错误提前到调用点**:
比赛时最贵的是 debug 时间,一个 `ValueError: 数组长度不一致` 比 比赛时最贵的是 debug 时间,一个 `ValueError: 数组长度不一致` 比
十层 traceback 里的 `IndexError` 值钱得多。 十层 traceback 里的 `IndexError` 值钱得多。
**所有收数值的函数都从 `as_array` 进**:它一次做完"类型白名单 + 转 ndarray + 告诉调用方原来是什么",
省掉每个函数各抄一遍 `isinstance(x, pd.DataFrame)` / `select_dtypes` / `np.asarray`。
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any, Literal, cast
import numpy as np import numpy as np
import pandas as pd
__all__ = [ __all__ = [
"as_array",
"check_array", "check_array",
"check_probability", "check_probability",
"check_same_length", "check_same_length",
"check_square_matrix", "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: def check_array(x: Any, ndim: int | None = None, finite: bool = True) -> np.ndarray:
"""转成 ndarray 并做基本检查(维度、有限值)。 """转成 ndarray 并做基本检查(维度、有限值)。
实现时应当复用 `as_array`(丢掉第二个返回值即可),不要重写一遍转换逻辑。
Examples Examples
-------- --------
>>> check_array([1, 2, 3]).shape >>> check_array([1, 2, 3]).shape
(3,) (3,)
""" """
# TODO(L0) # TODO(L1)
raise NotImplementedError raise NotImplementedError
@@ -38,17 +152,17 @@ def check_same_length(*arrays: Any) -> None:
-------- --------
>>> check_same_length([1, 2], [3, 4]) >>> check_same_length([1, 2], [3, 4])
""" """
# TODO(L0) # TODO(L1)
raise NotImplementedError raise NotImplementedError
def check_probability(p: float) -> float: def check_probability(p: float) -> float:
"""检查 p 落在 [0, 1],返回 float。""" """检查 p 落在 [0, 1],返回 float。"""
# TODO(L0) # TODO(L1)
raise NotImplementedError raise NotImplementedError
def check_square_matrix(a: Any) -> np.ndarray: def check_square_matrix(a: Any) -> np.ndarray:
"""检查是方阵,返回 ndarray。AHP 判断矩阵、转移矩阵都要用。""" """检查是方阵,返回 ndarray。AHP 判断矩阵、转移矩阵都要用。"""
# TODO(L0) # TODO(L1)
raise NotImplementedError raise NotImplementedError
+20 -42
View File
@@ -13,10 +13,9 @@ from typing import Any
import numpy as np import numpy as np
import pandas as pd import pandas as pd
__all__ = ["log_transform", "normalize", "one_hot_encode", "standardize"] from ..core.validate import as_array
# 数值输入白名单。DataFrame 必须写进来,否则下面所有 is_df 分支都是死代码。 __all__ = ["log_transform", "normalize", "one_hot_encode", "standardize"]
_NUMERIC_TYPES = (int, float, list, tuple, np.ndarray, pd.Series, pd.DataFrame)
def normalize(x: Any, method: str = "minmax", feature_range: tuple[float, float] = (0.0, 1.0)) -> Any: 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]: if feature_range[0] >= feature_range[1]:
raise ValueError(f"feature_range的下界必须小于上界,你传入了{feature_range}") raise ValueError(f"feature_range的下界必须小于上界,你传入了{feature_range}")
if not isinstance(x, _NUMERIC_TYPES): # numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。
raise TypeError(f"输入必须为数值型、列表、numpy array、pandas Series 或 DataFrame,你传入了{type(x)}") arr, kind = as_array(x, numeric_only=False)
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)
if method == "minmax": if method == "minmax":
x_min = np.min(arr, axis=0) 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) x_max = np.where(x_max == 0, 1.0, x_max)
result = arr / x_max result = arr / x_max
if is_df: if kind == "frame":
return pd.DataFrame(result, columns=original_cols, index=original_index) return pd.DataFrame(result, index=x.index, columns=x.columns)
if is_series: if kind == "series":
return pd.Series(result, index=original_index) return pd.Series(result, index=x.index, name=x.name)
return result return result
@@ -128,15 +120,8 @@ def standardize(x: Any, ddof: int = 0) -> Any:
常量列(`std == 0`)不产生 nan / inf,固定输出 0,与 `sklearn` 的 常量列(`std == 0`)不产生 nan / inf,固定输出 0,与 `sklearn` 的
`StandardScaler` 对零方差列的处理一致。 `StandardScaler` 对零方差列的处理一致。
""" """
if not isinstance(x, _NUMERIC_TYPES): # numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。
raise TypeError(f"输入必须为数值型、列表、numpy array、pandas Series 或 DataFrame,你传入了{type(x)}") arr, kind = as_array(x, numeric_only=False)
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)
mean = np.mean(arr, axis=0) mean = np.mean(arr, axis=0)
@@ -146,10 +131,10 @@ def standardize(x: Any, ddof: int = 0) -> Any:
result = (arr - mean) / std result = (arr - mean) / std
if is_df: if kind == "frame":
return pd.DataFrame(result, columns=original_cols, index=original_index) return pd.DataFrame(result, index=x.index, columns=x.columns)
if is_series: if kind == "series":
return pd.Series(result, index=original_index) return pd.Series(result, index=x.index, name=x.name)
return result 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() >>> log_transform([1.0, 2.718281828459045]).round(3).tolist()
[0.0, 1.0] [0.0, 1.0]
""" """
if not isinstance(x, _NUMERIC_TYPES): # numeric_only=False:输出要与输入**同形**,跳过非数值列会让返回的表少列、对不上原表。
raise TypeError(f"输入必须为数值型、列表、numpy array、pandas Series 或 DataFrame,你传入了{type(x)}") arr, kind = as_array(x, numeric_only=False)
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)
if offset is None: if offset is None:
min_val = np.min(arr) min_val = np.min(arr)
@@ -201,10 +179,10 @@ def log_transform(x: Any, offset: float | None = None) -> Any:
result = np.log(arr + offset) result = np.log(arr + offset)
if is_df: if kind == "frame":
return pd.DataFrame(result, columns=original_cols, index=original_index) return pd.DataFrame(result, index=x.index, columns=x.columns)
if is_series: if kind == "series":
return pd.Series(result, index=original_index) return pd.Series(result, index=x.index, name=x.name)
return result return result
+9 -23
View File
@@ -11,6 +11,8 @@ from typing import Any, cast
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from ..core.validate import as_array
__all__ = ["coefficient_of_variation", "describe", "zscore"] __all__ = ["coefficient_of_variation", "describe", "zscore"]
@@ -139,25 +141,7 @@ def zscore(x: Any, ddof: int = 0) -> Any:
与 `data.transform.standardize` 是同一个公式;差别只在 DataFrame 里遇到 与 `data.transform.standardize` 是同一个公式;差别只在 DataFrame 里遇到
非数值列时:这里**跳过**,`standardize` 会直接抛错。 非数值列时:这里**跳过**,`standardize` 会直接抛错。
""" """
allowed_types = (pd.DataFrame, pd.Series, list, tuple, np.ndarray) arr, kind = as_array(x, numeric_only=True)
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)
mean = np.mean(arr, axis=0) mean = np.mean(arr, axis=0)
std = np.std(arr, axis=0, ddof=ddof) 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) std = np.where(std == 0, 1.0, std)
result = (arr - mean) / std result = (arr - mean) / std
if is_df: if kind == "frame":
return pd.DataFrame(result, columns=original_cols, index=original_index) # 列名口径与 as_array(numeric_only=True) 一致,都是只取数值列
if is_series: cols = x.select_dtypes(include=[np.number]).columns
return pd.Series(result, index=original_index) return pd.DataFrame(result, index=x.index, columns=cols)
if kind == "series":
return pd.Series(result, index=x.index, name=x.name)
return result return result
+6 -23
View File
@@ -13,6 +13,8 @@ import numpy as np
import pandas as pd import pandas as pd
import scipy.stats as stats import scipy.stats as stats
from ..core.validate import as_array
__all__ = [ __all__ = [
"anova", "anova",
"chi_square_test", "chi_square_test",
@@ -252,24 +254,14 @@ def confidence_interval(
-------- --------
>>> lo, hi = confidence_interval([1, 2, 3, 4, 5], confidence=0.95) >>> lo, hi = confidence_interval([1, 2, 3, 4, 5], confidence=0.95)
""" """
if not isinstance(x, (list, tuple, np.ndarray, pd.Series, pd.DataFrame)): # 入口统一走 as_array(容器类型/数值转换的错误口径只有一处);样本是"一串数",显式摊平。
raise TypeError(f"x 必须是 list / tuple / ndarray / Series / DataFrame,而你传入了{type(x)}") arr, _ = as_array(x, numeric_only=True, ndim=1)
if method not in _VALID_CONFIDENCE_METHODS: if method not in _VALID_CONFIDENCE_METHODS:
raise ValueError(f"不支持的方法:{method},仅支持{list(_VALID_CONFIDENCE_METHODS)}") raise ValueError(f"不支持的方法:{method},仅支持{list(_VALID_CONFIDENCE_METHODS)}")
if not 0 < confidence < 1: if not 0 < confidence < 1:
raise ValueError(f"confidence 必须在 (0, 1) 开区间内,而你传入了{confidence}") 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)] arr = arr[~np.isnan(arr)]
n = len(arr) n = len(arr)
if n < 2: 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): if not (0 < alpha < 1):
raise ValueError(f"显著性水平 alpha 必须在 (0, 1) 之间,你传入了 {alpha}") raise ValueError(f"显著性水平 alpha 必须在 (0, 1) 之间,你传入了 {alpha}")
def _clean_data(data: Any) -> np.ndarray: def _clean_data(data: Any) -> np.ndarray:
if isinstance(data, pd.DataFrame): arr, _ = as_array(data, numeric_only=True, ndim=1)
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
return arr[~np.isnan(arr)] return arr[~np.isnan(arr)]
a_arr = _clean_data(a) a_arr = _clean_data(a)
+79 -1
View File
@@ -1,11 +1,12 @@
"""core 包测试。 """core 包测试。
TODO(L0):TestValidate 实现后去掉 skip 标记(Result 与 Random 已完成)。 TODO(L0):TestValidate 的 check_* 实现后去掉 skip 标记(Result / Random / as_array 已完成)。
""" """
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
import pandas as pd
import pytest import pytest
@@ -96,6 +97,83 @@ class TestValidate:
check_square_matrix([[1, 2, 3], [4, 5, 6]]) 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(): def test_result_repr_is_readable():
from mcm_toolkit.core import Result from mcm_toolkit.core import Result
+12
View File
@@ -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) 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(): def test_zscore_rejects_bad_type_and_all_text_frame():
from mcm_toolkit.stats import zscore from mcm_toolkit.stats import zscore