## 新增 - `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`。
221 lines
8.3 KiB
Python
221 lines
8.3 KiB
Python
"""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("> 样本太少")
|