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

+79 -1
View File
@@ -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