Files
MCM-ICM_Toolkit/tests/test_data.py
T
guzhujushi 5749e50e83 chore: 补 S 档卡片 / examples;clean.py 收尾,xfail 清零
实现收尾(Codex 代写,用户授权):
- impute_missing 的 mode 分支改为返回新表(原先就地改调用方的 DataFrame,另外四支都返回新对象)
- detect_outliers 补入参类型校验(§4.7:非法类型抛 TypeError,不静默兜底):
  接受 DataFrame / Series / list / tuple / ndarray;tuple 同时补进 is_ld_array;
  dict 不再被接受(以前靠 pd.DataFrame() 碰巧能过,见决策 33)

测试:
- 摘掉最后一条 xfail(mode 就地改)并加强断言
- 新增 3 条:非法类型抛 TypeError、元组按一维数组型处理、全 NaN 列与单元素列不抛异常
- 64 passed / 42 skipped / 0 xfailed(xfail 清零,7 条契约全部转正);ruff 0;pyright 0

文档:
- 新增 docs/algorithms/detect_outliers.md —— 第一张 S 档函数卡片(roadmap 要求约 15 张)
  除模板九节外多记三条判断:为什么不返回 Result、非数值列恒 False 的两条备选路各错在哪、
  ddof 口径与 stats.zscore 不一致(前者 1、后者 0,小样本差一成)
- 新增 examples/01_clean_and_describe.py —— 六个 data 函数的最小用法,实测跑通(exit 0)
- STATUS.md:E 节收尾、验证结果表、下一步重写(transform/standardize 的待定项)、决策 33
- CHANGELOG.md:把 [Unreleased] 里重复 7 次的 Added/Changed 小节合并成 Added/Changed/Notes;
  并修掉上轮遗留的缺陷 —— pyright 那条 bullet 的开头被吞掉,半句挂在 ruff 那条后面
2026-10-02 15:48:12 +08:00

388 lines
13 KiB
Python
Raw 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.
"""data 包测试。
L0 阶段的约定:**只测已实现的行为**。未实现的算法用逐条 skip 标出,
不要用模块级 pytestmark —— 那会把同文件里已实现的函数一起跳过,
让 load_table 这种已经写好的函数变成零覆盖。
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L0/L1"
# ---------------------------------------------------------------- io: load_table
def test_load_table_detects_gbk(tmp_path):
"""国内赛题数据常见 GBK,编码探测必须能兜住。"""
from mcm_toolkit.data import load_table
p = tmp_path / "gbk.csv"
p.write_bytes("城市,人口\n北京,2189\n".encode("gbk"))
df = load_table(p)
assert list(df.columns) == ["城市", "人口"]
def test_load_table_keeps_utf8_readable(tmp_path):
"""UTF-8 文件必须按 UTF-8 读出,不能先试 GBK 变成乱码。"""
from mcm_toolkit.data import load_table
p = tmp_path / "utf8.csv"
p.write_text("省份,GDP增速\n广东,5.8\n", encoding="utf-8")
df = load_table(p)
assert list(df.columns) == ["省份", "GDP增速"]
assert df.loc[0, "省份"] == "广东"
def test_load_table_maps_na_like_tokens_to_nan(tmp_path):
"""赛题里的缺失值长得五花八门,默认 na_values 要全部吃成 NaN。"""
from mcm_toolkit.data import load_table
p = tmp_path / "na.csv"
p.write_text("a,b,c\n1,NA,\n2,-,#N/A\n", encoding="utf-8")
df = load_table(p)
# 外面套 bool():df["b"] 的类型是 Series | DataFrame,直接当条件用 pyright 会报
# "条件操作数无效";套上 bool() 既满足类型检查,又能在它真返回 Series 时当场炸掉。
assert bool(df["b"].isna().all())
assert bool(df["c"].isna().all())
assert bool(df["a"].notna().all())
def test_load_table_respects_explicit_encoding(tmp_path):
"""显式给了 encoding 就照办,不再做探测。"""
from mcm_toolkit.data import load_table
p = tmp_path / "gbk.csv"
p.write_bytes("城市,人口\n上海,2487\n".encode("gbk"))
df = load_table(p, encoding="gbk")
assert list(df.columns) == ["城市", "人口"]
def test_load_table_rejects_unknown_suffix(tmp_path):
"""不支持的扩展名要抛 ValueError,而且要带上后缀名。"""
from mcm_toolkit.data import load_table
p = tmp_path / "data.txt"
p.write_text("a,b\n1,2\n", encoding="utf-8")
with pytest.raises(ValueError, match=r"\.txt"):
load_table(p)
def test_load_table_missing_file_raises(tmp_path):
"""文件不存在时要把路径原样带出来,方便定位。"""
from mcm_toolkit.data import load_table
missing = tmp_path / "nope.csv"
with pytest.raises(FileNotFoundError, match=r"nope\.csv"):
load_table(missing)
# ---------------------------------------------------------------- io: save_table
def test_save_table_round_trip(tmp_path):
"""存进去再读出来,内容要一致,并且返回实际写入的 Path。"""
from mcm_toolkit.data import load_table, save_table
df = pd.DataFrame({"城市": ["北京", "上海"], "人口": [2189.0, 2487.0]})
out = save_table(df, tmp_path / "out.csv")
assert isinstance(out, Path)
assert out.exists()
back = load_table(out)
assert list(back.columns) == ["城市", "人口"]
assert back["人口"].tolist() == [2189.0, 2487.0]
def test_save_table_rejects_non_dataframe(tmp_path):
"""传错类型要当场报错,不要写出一个奇怪的文件。"""
from mcm_toolkit.data import save_table
with pytest.raises(TypeError):
save_table([1, 2, 3], tmp_path / "bad.csv") # type: ignore[arg-type] # 就是要传错
def test_save_table_validates_before_touching_disk(tmp_path):
"""校验必须早于副作用:参数不合法时,一个目录、一个文件都不能留下。"""
from mcm_toolkit.data import save_table
with pytest.raises(ValueError, match=r"\.txt"):
save_table(pd.DataFrame({"a": [1]}), tmp_path / "d1" / "x.txt")
with pytest.raises(TypeError):
save_table([1, 2, 3], tmp_path / "d2" / "x.csv") # type: ignore[arg-type] # 就是要传错
assert sorted(p.name for p in tmp_path.iterdir()) == []
# --------------------------------------------------------------- data: clean
def test_clean_missing_default_drops_any_missing():
"""不传 threshold 时是"只要有缺失就删"。"""
from mcm_toolkit.data import clean_missing
df = pd.DataFrame({"a": [1.0, np.nan, 3.0], "b": [1.0, 2.0, np.nan]})
out = clean_missing(df)
assert len(out) == 1
assert list(out.columns) == ["a", "b"]
def test_clean_missing_threshold_keeps_rows_within_limit():
"""四行三列、阈值 0.5:缺 2/3 的两行要删,剩 2 行。
3 列时 min_non_na 必须是 ceil(3*0.5)=2;写成 int() 截断就会漏删。
"""
from mcm_toolkit.data import clean_missing
df = pd.DataFrame(
{
"a": [1.0, np.nan, np.nan, 4.0],
"b": [1.0, np.nan, np.nan, 4.0],
"c": [1.0, 2.0, np.nan, 4.0],
}
)
out = clean_missing(df, axis=0, threshold=0.5)
assert len(out) == 2
assert out["a"].tolist() == [1.0, 4.0]
def test_clean_missing_axis1_drops_empty_column():
"""axis=1 按列删:整列全空(缺失率 1.0 > 0.5)要掉。"""
from mcm_toolkit.data import clean_missing
df = pd.DataFrame({"x": [1.0, 2.0, 3.0], "y": [np.nan, np.nan, np.nan]})
assert list(clean_missing(df, axis=1, threshold=0.5).columns) == ["x"]
def test_clean_missing_rejects_bad_axis():
from mcm_toolkit.data import clean_missing
with pytest.raises(ValueError, match="axis"):
clean_missing(pd.DataFrame({"a": [1.0]}), axis=2)
def test_clean_missing_rejects_non_dataframe():
from mcm_toolkit.data import clean_missing
with pytest.raises(TypeError):
clean_missing([[1.0, 2.0]])
def test_impute_missing_mean_and_ffill():
from mcm_toolkit.data import impute_missing
df = pd.DataFrame({"a": [1.0, np.nan, 3.0]})
assert impute_missing(df, method="mean")["a"].tolist() == [1.0, 2.0, 3.0]
assert impute_missing(df, method="ffill")["a"].tolist() == [1.0, 1.0, 3.0]
def test_impute_missing_mode_survives_all_nan_column():
"""整列全 NaN 时 mode() 给不出众数:不能崩,也不能硬填一个值进去。"""
from mcm_toolkit.data import impute_missing
df = pd.DataFrame({"a": [np.nan, np.nan], "b": [1.0, np.nan]})
out = impute_missing(df, method="mode")
assert bool(out["a"].isna().all())
assert out["b"].tolist() == [1.0, 1.0]
def test_impute_missing_rejects_unknown_method():
from mcm_toolkit.data import impute_missing
with pytest.raises(ValueError, match="bogus"):
impute_missing(pd.DataFrame({"a": [1.0]}), method="bogus")
def test_impute_missing_rejects_non_dataframe():
from mcm_toolkit.data import impute_missing
with pytest.raises(TypeError):
impute_missing([[1.0, 2.0]])
def test_winsorize_clips_series_to_quantiles():
from mcm_toolkit.data import winsorize
s = pd.Series([1.0, 2.0, 3.0, 4.0, 1000.0])
out = winsorize(s, lower=0.1, upper=0.9)
assert float(out.iloc[0]) == pytest.approx(1.4)
assert float(out.iloc[-1]) == pytest.approx(601.6)
assert float(out.max()) < 1000.0
def test_winsorize_rejects_bad_quantile_order():
from mcm_toolkit.data import winsorize
with pytest.raises(ValueError, match="lower"):
winsorize(pd.Series([1.0, 2.0]), lower=0.9, upper=0.1)
def test_winsorize_rejects_non_frame_with_typeerror():
"""§4.7:类型不对必须抛 TypeError,不能抛 ValueError。"""
from mcm_toolkit.data import winsorize
with pytest.raises(TypeError):
winsorize([1.0, 2.0, 3.0])
def test_detect_outliers_rejects_unknown_method():
"""方法名校验在取默认取值之前,所以这条现在就是绿的。"""
from mcm_toolkit.data import detect_outliers
with pytest.raises(ValueError, match="bogus"):
detect_outliers([1.0, 2.0], method="bogus") # type: ignore[arg-type]
def test_detect_outliers_rejects_bad_type():
"""§4.7:类型不对必须抛 TypeError,不能让它一路飘进 pandas 里去。"""
from mcm_toolkit.data import detect_outliers
for bad in (42, 3.14, "123", None, {"a": [1, 2]}):
with pytest.raises(TypeError):
detect_outliers(bad)
def test_detect_outliers_survives_degenerate_columns():
"""全 NaN 列与单元素列都不能抛异常,判定一律为 False。"""
from mcm_toolkit.data import detect_outliers
df = pd.DataFrame({"all_nan": [np.nan, np.nan, np.nan], "one": [1.0, 2.0, 3.0]})
assert not bool(np.asarray(detect_outliers(df)).any())
assert not bool(np.asarray(detect_outliers([5.0])).any())
assert not bool(np.asarray(detect_outliers([5.0], method="zscore")).any())
def test_detect_outliers_accepts_tuple_as_1d():
"""元组按一维数组型处理,返回值与传 list 一致。"""
from mcm_toolkit.data import detect_outliers
mask = np.asarray(detect_outliers((1.0, 2.0, 3.0, 4.0, 1000.0)))
assert mask.shape == (5,)
assert mask.dtype == bool
assert bool(mask[-1])
# ------------------------ 曾经 xfail 的契约,现已全部实现(留作回归)
#
# 这些用例写的是**文档里已经承诺的行为**。实现没跟上时用 xfail,让它们照样出现在
# 报告里(显示成 x);修好之后会自动变成 XPASS —— 看到 XPASS 就把 mark 摘掉。
#
# 已摘掉:threshold 范围校验、IQR 的 UnboundLocalError、(n,1) 形状、zscore 默认阈值、
# winsorize 的文本列、detect_outliers 的文本列、impute_missing 的 mode 就地修改。
# **当前 0 条 xfail** —— 以后新增契约照旧:先写测试 + xfail,实现跟上就摘 mark。
def test_detect_outliers_finds_planted_points():
"""人工埋 3 个离群点,IQR 法应正好命中 3 个。"""
from mcm_toolkit.data import detect_outliers
x = np.concatenate([np.arange(100.0), [1e6, -1e6, 1e7]])
mask = np.asarray(detect_outliers(x, method="iqr"))
assert int(mask.sum()) == 3
assert bool(mask[-3:].all())
def test_detect_outliers_returns_mask_not_filtered_data():
"""约定:只标不删——返回与原数据同形的布尔掩码。"""
from mcm_toolkit.data import detect_outliers
x = np.arange(50.0)
mask = np.asarray(detect_outliers(x, method="zscore"))
assert mask.shape == x.shape
assert mask.dtype == bool
def test_detect_outliers_zscore_flags_planted_point():
from mcm_toolkit.data import detect_outliers
x = np.concatenate([np.zeros(99), [1e6]])
mask = np.asarray(detect_outliers(x, method="zscore"))
assert int(mask.sum()) == 1
assert bool(mask[-1].all())
def test_detect_outliers_survives_text_columns():
"""赛题表几乎都带文本列:分位数只在数值列上算,文本列恒 False。"""
from mcm_toolkit.data import detect_outliers
df = pd.DataFrame({"city": ["a", "b", "c", "d"], "v": [1.0, 2.0, 3.0, 1000.0]})
mask = detect_outliers(df)
assert int(np.asarray(mask).sum()) == 1
assert not bool(np.asarray(mask["city"]).any())
assert bool(np.asarray(mask["v"]).any())
def test_detect_outliers_all_text_frame_returns_all_false():
"""整表没有数值列时不报错,返回全 False 掩码(语义:这一列没有离群点)。"""
from mcm_toolkit.data import detect_outliers
df = pd.DataFrame({"a": ["x", "y"], "b": ["z", "w"]})
mask = detect_outliers(df)
assert mask.shape == df.shape
assert not bool(np.asarray(mask).any())
def test_winsorize_survives_text_columns():
"""文本列必须原样留着:分位数只在数值列上算。"""
from mcm_toolkit.data import winsorize
df = pd.DataFrame({"city": ["a", "b", "c", "d"], "v": [1.0, 2.0, 3.0, 1000.0]})
out = winsorize(df)
assert out["city"].tolist() == ["a", "b", "c", "d"]
assert float(out["v"].iloc[-1]) < 1000.0
def test_clean_missing_rejects_out_of_range_threshold():
from mcm_toolkit.data import clean_missing
with pytest.raises(ValueError, match="threshold"):
clean_missing(pd.DataFrame({"a": [1.0, np.nan]}), threshold=1.5)
def test_impute_missing_mode_does_not_mutate_input():
"""mode 分支也必须返回新表:不能顺手把调用方的表改了。"""
from mcm_toolkit.data import impute_missing
df = pd.DataFrame({"a": [1.0, np.nan, 1.0]})
out = impute_missing(df, method="mode")
assert out["a"].tolist() == [1.0, 1.0, 1.0]
assert bool(df["a"].isna().any())
# ------------------------------------------------- 以下为未实现算法(L0/L1 待写)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_normalize_minmax_range(rng):
from mcm_toolkit.data import normalize
x = rng.normal(size=(50, 3))
out = np.asarray(normalize(x, method="minmax"))
assert np.allclose(out.min(axis=0), 0.0)
assert np.allclose(out.max(axis=0), 1.0)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_standardize_has_zero_mean_unit_std(rng):
from mcm_toolkit.data import standardize
x = rng.normal(5, 3, size=1000)
out = np.asarray(standardize(x))
assert abs(out.mean()) < 1e-9
assert abs(out.std() - 1.0) < 1e-9
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_train_test_split_sizes_and_no_overlap(rng):
from mcm_toolkit.data import train_test_split
X = rng.normal(size=(100, 2))
Xtr, Xte = train_test_split(X, test_size=0.2, random_state=0)
assert len(Xtr) == 80 and len(Xte) == 20
assert len({tuple(r) for r in Xtr} & {tuple(r) for r in Xte}) == 0