Files
guzhujushi b70a96dd0c 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`。
2026-10-03 19:17:22 +08:00

221 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""core 包测试。
TODO(L0):TestValidate 的 check_* 实现后去掉 skip 标记(Result / Random / as_array 已完成)。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
class TestRandom:
def test_get_rng_is_reproducible(self):
from mcm_toolkit.core import get_rng
assert get_rng(42).random() == get_rng(42).random()
def test_get_rng_passes_through_generator(self):
from mcm_toolkit.core import get_rng
g = np.random.default_rng(0)
assert get_rng(g) is g
def test_get_rng_accepts_numpy_integer(self):
"""np.int64 不是 int 的子类,必须当成整数处理,不能静默退化成全局流。"""
from mcm_toolkit.core import get_rng, set_seed
set_seed(1)
first = get_rng(np.int64(42))
second = get_rng(np.int64(42))
assert first is not second, "传 np.integer 应当新建独立生成器"
assert first.random() == np.random.default_rng(42).random()
def test_get_rng_rejects_bad_types(self):
"""类型不对就报错。静默当成"没传种子",换来的只会是不可复现的结果。"""
from mcm_toolkit.core import get_rng
for bad in ["42", 42.0, True, [42]]:
with pytest.raises(TypeError):
get_rng(bad)
def test_get_rng_without_seed_shares_and_advances(self):
from mcm_toolkit.core import get_rng, set_seed
set_seed(3)
assert get_rng() is get_rng(), "无参调用应返回同一个共享生成器"
assert get_rng().random() != get_rng().random(), "共享生成器必须被推进"
def test_get_rng_default_follows_global_seed(self):
"""set_seed 之后,不传 random_state 也必须复现——这是"论文能重跑"的底线。"""
from mcm_toolkit.core import get_rng, set_seed
set_seed(7)
first = get_rng().random()
set_seed(7)
second = get_rng().random()
assert first == second
def test_set_seed_also_covers_third_party_global_state(self):
"""sklearn / statsmodels 走的是 np.random 旧全局,set_seed 要连它一起覆盖。"""
from mcm_toolkit.core import set_seed
set_seed(7)
first = np.random.random() # noqa: NPY002 - 这里就是要验证旧全局被设上了
set_seed(7)
second = np.random.random() # noqa: NPY002
assert first == second
@pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0")
class TestValidate:
def test_check_same_length_raises_with_detail(self):
from mcm_toolkit.core import check_same_length
with pytest.raises(ValueError, match=r"2.*3|长度"):
check_same_length([1, 2], [1, 2, 3])
def test_check_array_rejects_nan(self):
from mcm_toolkit.core import check_array
with pytest.raises(ValueError):
check_array([1.0, np.nan, 2.0], finite=True)
def test_check_probability_bounds(self):
from mcm_toolkit.core import check_probability
assert check_probability(0.5) == 0.5
with pytest.raises(ValueError):
check_probability(1.5)
def test_check_square_matrix(self):
from mcm_toolkit.core import check_square_matrix
check_square_matrix([[1, 2], [3, 4]])
with pytest.raises(ValueError):
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
text = repr(Result("demo", params={"a": 1}, metrics={"r2": 0.9}))
# 只断言关键信息在场,不断言完整字符串——否则以后调格式会让测试集体变红
assert "demo" in text and "r2" in text
def test_result_repr_stays_short_and_safe():
"""默认值、失败结果、十万级数组:repr 都不能崩,也不能刷屏。"""
from mcm_toolkit.core import Result
assert "empty" in repr(Result("empty"))
failed = repr(Result("boom", success=False, message="求解未收敛"))
assert "success=False" in failed
assert "求解未收敛" in failed
noisy = repr(Result("mc", data=np.arange(100_000)))
assert "ndarray(100000)" in noisy
assert len(noisy) < 200, "data 不应被整个打印出来"
def test_result_summary_is_markdown_table():
from mcm_toolkit.core import Result
result = Result("topsis", params={"n": 3}, metrics={"best": 0.83})
text = result.summary()
lines = text.splitlines()
assert lines[0] == "### topsis"
assert lines[2] == "| 项 | 值 |"
assert "| success | True |" in lines
assert "| params.n | 3 |" in lines
assert "| metrics.best | 0.83 |" in lines
assert result.summary() == text, "summary 必须无副作用、可重复调用"
def test_result_summary_appends_message_on_failure():
from mcm_toolkit.core import Result
text = Result("gm11", success=False, message="样本太少").summary()
assert "| success | False |" in text
assert text.rstrip().endswith("> 样本太少")