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

+3 -1
View File
@@ -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",
+120 -6
View File
@@ -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
+20 -42
View File
@@ -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
+9 -23
View File
@@ -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
+6 -23
View File
@@ -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)