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:
1 parent
f95fbd2726
commit
b70a96dd0c
12 files changed
+356
-101
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user