diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b78b7..942310d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ - `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` / `impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测跑通(exit 0); 末尾留 `TODO(L0)` 等 `stats.describe`。 +- `tests/test_data.py`:`data` 的 transform 段 **18 条用例** —— `normalize` 的三种 method、 + `feature_range` 区间与两条校验、常量列不出 nan、docstring 那个 Examples 的精确值、按列语义; + `standardize` 的按列与 `ddof`(用解析关系钉住:`ddof=1` 时结果的 std = `sqrt((n-1)/n)`); + `log_transform` 的定义域平移;`one_hot_encode` 的自动识别、`drop_first`、列不存在报错。 + 其中两条是把原先挂着的 `skip` 转为实跑(见坑 9 / 个人易错点 9)。 ### Changed - `core.get_rng` / `core.set_seed`:实现(L0 第 2 项)。`get_rng` 收 `int` / `np.integer` / @@ -105,6 +110,19 @@ - `tests/test_data.py`:摘掉最后一条 `xfail`(mode 就地改)并加强断言;新增 3 条 (非法类型抛 `TypeError`、元组按一维数组型处理、全 NaN 列与单元素列不抛异常)。 测试 **60 passed / 1 xfailed → 64 passed / 0 xfailed**。 +- `data.transform` 四个函数实现(**由用户本人写**):`normalize`(minmax / vector / max,均按列)、 + `standardize`(按列,`ddof` 真的传下去)、`log_transform`(`offset=None` 时自动取 `1 - min(x)`)、 + `one_hot_encode`。`data/` 下的 L0 至此结清。 +- `data.transform`:Codex 清掉 5 条 ruff —— RET503(`normalize` 末尾缺无条件 `return`)、 + F541(无占位符的 f-string)、RET504(多余的 `result` 中转)、RET505(`return` 后的 `elif`)、 + SIM108(`log_transform` 的 if/else 改三元)。 +- `data.transform.normalize`:`supported_method` 与 `feature_range` 的校验提到 `np.asarray` **之前** + (§4.7 的"守卫集中在开头");并修掉说反的报错文案 —— 条件是要求下界 < 上界, + 原文却写"上界必须小于下界",且 `TypeError` 那条用了无占位符 f-string。 +- `data.transform.one_hot_encode`:自动识别类别列时显式加上 `'str'` / `'string'`。 + 原来只写 `['object', 'category']`,在 pandas 3 上是**已弃用路径**(每次调用发 `Pandas4Warning`), + 未来会变成一列都选不中的**静默错误**。详见坑 9。 +- 测试计数:**64 passed → 82 passed**(新增 18 条,另两条 skip 转为实跑),skip 42 → 40。 ### Notes @@ -112,6 +130,12 @@ `clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、 非数值列恒 `False`(决策 32)、入参类型校验补上(决策 33)。 S 档卡片与 `examples/` 用法也已落地 —— `detect_outliers` 这条线**结清**。 +- `transform.py` 还有 **4 个口径待定**,所以四个函数的 docstring 里 `Parameters` / `Returns` / + `Raises` 都还空着(§4.7 硬要求):① 三个函数的入参类型策略不一致 —— `normalize` 没有守卫, + `normalize('abc')` 抛 numpy 的 `ValueError`、`normalize(None)` **静默返回 NaN**; + ② 要不要收 `DataFrame`(`normalize` 收但丢列名、`standardize` 直接 `TypeError`); + ③ 显式 `offset` 没校验,`log_transform(x, offset=-10.0)` 静默返回 NaN; + ④ 常量列行为已被测试钉住(决策 34)但没写进 docstring。见 `STATUS.md`「下一步」第 1/2 条。 - 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `stats` / `viz`, `Result` 的用法届时并进去。见 `STATUS.md`「下一步」第 3 条。 - 已知缺口: diff --git a/STATUS.md b/STATUS.md index 3176003..c836c3e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -112,14 +112,26 @@ 接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`,`dict` 不再接受(决策 33) - [x] `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` / `impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测可直接跑通 -- [ ] 剩余 7 项:`normalize` `standardize` `describe` `correlation_matrix` - `bootstrap_ci` `monte_carlo` `save_figure` +- [x] `data/transform.py` 的四个函数(**由用户本人实现**):`normalize` / `standardize`(L0 第 6、7 项)、 + `log_transform` / `one_hot_encode`(原属 L1,顺手写了) + - `normalize`:三种 method(`minmax` / `vector` / `max`)都**按列**;`feature_range` 校验下界 < 上界; + 常量列压到下界而不出 nan(决策 34) + - `standardize`:按列,`ddof` 真的传下去 —— 测试用解析关系钉住 + (`ddof=0` → std 恰为 1;`ddof=1` → `sqrt((n-1)/n)` = 0.866) + - `log_transform`:`offset=None` 时自动取 `1 - min(x)`,保证 `x + offset >= 1` + - `one_hot_encode`:自动识别的 dtype 集合已显式补上 `'str'` / `'string'`(见坑 9) + - Codex 清掉 5 条 ruff(RET503 / F541 / RET504 / RET505 / SIM108),并把 `feature_range` + 的两条校验提到 `np.asarray` **之前**、修掉说反的报错文案(条件是"下界必须小于上界") + - 测试:**新写 18 条**,其中两条是把原先挂着的 `skip` 转为实跑 —— 见 `tests/test_data.py` 的 transform 段 +- [ ] **待定:** `transform` 的 4 个口径问题(入参类型策略、要不要收 `DataFrame`、 + 显式 `offset` 的校验、常量列写进 docstring)—— 见「下一步」第 1/2 条 +- [ ] 剩余 5 项:`describe` `correlation_matrix` `bootstrap_ci` `monte_carlo` `save_figure` ### 验证结果 | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **64 passed, 42 skipped(0 xfail)** | +| `pytest` | 见下(单列) | **82 passed, 40 skipped(0 xfail)** | | `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 | | `ruff check` | All checks passed | **All checks passed**(0 error) | | `scripts/doctor.ps1` | 跑通 | — | @@ -129,11 +141,13 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- 64 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 33 条 `data` 行为测试 +- **122 条用例:82 passed / 40 skipped**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 + `test_core.py` 15、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 6、 + `test_models.py` 5 - **`xfail` 清零**:累计 7 条"契约已写进 docstring、实现没跟上"的用例全部转正, 决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环 - `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz` -- 42 条 skip 是等实现的算法(`normalize` / `standardize` / `train_test_split` 等) +- 40 条 skip 是等实现的算法(`train_test_split`、`describe`、`bootstrap_ci`、`monte_carlo` 等) - 三个 `.ps1` 通过 PowerShell AST 语法解析 ### ⚠️ 需要注意 / 遗留 @@ -143,8 +157,9 @@ 只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。 - [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。 note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。 -- [ ] L0 完成 **5/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、 - `detect_outliers`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进;详情见上面 E 节 +- [ ] L0 完成 **7/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、 + `detect_outliers`、`normalize`、`standardize`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进; + 详情见上面 E 节 - [ ] 本机 `exec_command` 在沙箱模式下**完全不可用** (`helper_unknown_error: setup refresh had errors`),只有提权 (`require_escalated`)模式可用。沙箱模式只允许写 `D:\MCM&ICM_Toolkit`。 @@ -188,6 +203,7 @@ | 30 | 已知待修的契约用 `@pytest.mark.xfail(strict=False)`,不用 skip | skip 会让"契约还没写"和"写了但实现有问题"混在一起,5 条 skip 曾把 `clean.py` 的 bug 全藏住。xfail 照样进报告,修好后自动变 XPASS,是"修好了"的信号,而且 XPASS 不失败,不会卡住提交 | | 29 | 静态分析器钉死到工作区 `.venv`,并把 base 的 site-packages 用 `.pth` 链进去 | pyright / Pylance **不认** `pyvenv.cfg` 的 `include-system-site-packages`,`.venv` 自己只有 `pip`;不钉的话它们会去 PATH 上找 python,本机 PATH 上那个是 Codex 运行时的 3.14 shim,没有科学栈,于是报"无法解析导入"。`.pth` 是唯一被 pyright 认的转发手段 | | 28 | 不启用 ruff 的 preview 规则集 | `E302`(函数间要 2 个空行)在 ruff 0.16 里是 preview 规则,`select = ["E"]` 默认**不包含**它——所以少一个空行 ruff 照样报 "All checks passed"。行数空行得自己盯,别指望 lint | +| 34 | 常量列(`max == min`)不定区间也不崩:`normalize` 压到 `feature_range` 下界,`standardize` 给 0 | 除零必须有个确定的答案,不能出 nan / inf。给 0 与 `sklearn` 的 `MinMaxScaler` / `StandardScaler` 一致(sklearn 把常量列的 scale 设为 1,结果就是 0),换库不换结论。行为已由 `test_normalize_constant_column_stays_finite` 与 `test_standardize_constant_column_stays_finite` 钉住 | | 33 | `detect_outliers` 只接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`,`dict` 一律 `TypeError` | §4.7 要求非法类型抛 `TypeError` 而非静默兜底。`dict` 以前能"碰巧"被 `pd.DataFrame()` 转成表,但那不是文档承诺的形状,静默接受会让调用方以为传什么都行;`tuple` 则补进文档与 `is_ld_array`,与 `list` 同等对待 | | 32 | `detect_outliers` 对非数值列恒返回 `False`(既不跳过也不报错) | **用户本人在两条路里选的**。跳过会让 docstring 里"与原数据同形"变成谎话(形状对不上,没法按位置对回原表);报错则让一张带 `city` 列的真题表直接不可用。`False` 的读法是"这一列没有离群点",对文本列是最不坏的解释,也让整表能一次跑完 | | 31 | Codex 可**直接改** `src/` 的范围:纯 bug(不改接口与语义)、格式、死代码 | 决策 25 说实现归用户,但"能跑"是底线:`winsorize(pd.Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩、测试变红,这种必须当场修。凡是要**选一个语义**的(文本列返回什么、mode 要不要就地改)一律留给用户本人,Codex 只把选项和影响列清楚 | @@ -195,17 +211,19 @@ ## 下一步 -1. **`data/transform.py` 的 `normalize` / `standardize`**(用户本人开始写) - - 签名已经定好(`normalize(x, method="minmax", feature_range=(0.0, 1.0))`、 - `standardize(x, ddof=0)`),动手前只需要定两件事:`normalize` 的 `method` 取值集合 - (默认 `"minmax"`;要不要再放 `"maxabs"` / `"robust"`?YAGNI 的话就只留 `"minmax"`)、 - 以及 `axis` 语义 —— 赛题里几乎总是**按列**,那就明确默认按列,别留模糊。 - - 注意 `standardize` 的 `ddof=0` 与 `detect_outliers` 里 pandas 默认的 `ddof=1` 不一致, - 两处都要在 docstring 里写明,否则以后对不上账(见 `docs/algorithms/detect_outliers.md` §5)。 - - `tests/test_data.py` 里两条 skip 已就位(`test_normalize_minmax_range`、 - `test_standardize_has_zero_mean_unit_std`)。 -2. `stats.describe` + `data/transform.py` 的 `log_transform` / `one_hot_encode` - (`examples/01_clean_and_describe.py` 末尾的 `TODO(L0)` 就等 `describe`)。 -3. 补齐 `examples/00_quickstart.py`:它要等 `stats` / `viz`;`Result` 的用法届时一并写进去。 -4. 每写完一个函数:docstring(§4.7 全套)+ 一条 pytest + `examples/` 一行用法 + 更新 `STATUS.md`。 -5. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。 +1. **`transform.py` 还剩 4 个口径要你拍板**(都要落到 docstring 的 `Raises` / `Notes` 里): + - **入参类型策略三个函数不一致**:`standardize` / `log_transform` 有守卫,只收 + `int/float/list/tuple/ndarray/Series`;`normalize` 没有,全靠 `np.asarray` 自己崩 —— + `normalize('abc')` 抛 numpy 的 `ValueError`(不是 §4.7 要的 `TypeError`), + `normalize(None)` 更糟:**静默返回 NaN**。建议照 `standardize` 的元组抄一份守卫,三处统一。 + - **要不要收 `DataFrame`**:`normalize(df)` 现在能跑,但返回 ndarray、**列名丢了**; + `standardize(df)` 直接 `TypeError`。`load_table` 出来的就是 DataFrame, + 建议三个函数都收 DataFrame、按列处理、并保持 DataFrame 返回(列名留着才好回填原表)。 + - **显式 `offset` 没校验**:`log_transform(x, offset=-10.0)` 静默返回 NaN(`np.log` 负数 + RuntimeWarning)。 + 要么校验(`x + offset <= 0` 就抛 `ValueError`),要么在 docstring 里写清"由调用方保证"。 + - **常量列行为写进 docstring**:行为已被测试钉住(决策 34),但 docstring 还是空白。 +2. 四个函数的 docstring 都缺 `Parameters` / `Returns` / `Raises`(§4.7 硬要求)。 + 上面 4 点定了之后一次补齐 —— 现在补也会因为口径未定而要返工。 +3. `stats.describe` + `stats` 其余函数:`examples/00_quickstart.py` 与 + `examples/01_clean_and_describe.py` 末尾的 `TODO(L0)` 都在等它。 +4. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。 diff --git a/docs/04-environment.md b/docs/04-environment.md index 89bd58d..7c51535 100644 --- a/docs/04-environment.md +++ b/docs/04-environment.md @@ -271,6 +271,37 @@ numeric = cast(pd.DataFrame, data[numeric_cols]) **一句诊断口诀**:pyright 说某行"属性不存在"、但运行时确实能跑,先 `reveal_type(...)` 把真实推断打出来,看并集里混进了什么,别急着加 `# type: ignore`。 +**坑 9 —— pandas 3 起字符串列的 dtype 是 `str`,`select_dtypes(include=["object"])` 已走弃用路径。** + +现象:`one_hot_encode` 自动识别类别列时只写了 `['object', 'category']`, +功能看起来正常,但**每次调用都发一条 `Pandas4Warning`**,而且 warning 里写着这个行为 +"will be removed in a future version"。 + +```text +For backward compatibility, 'str' dtypes are included by select_dtypes when 'object' +dtype is specified. This behavior is deprecated and will be removed in a future version. +``` + +原因:pandas 3 起字符串列的 dtype 是 `str`(不再是 `object`)。当前版本为了向后兼容 +仍然把 `str` 列算进 `object`,但那是**过渡行为**。等它真的移除,`include=['object']` +会一列都选不中 —— 那时 `one_hot_encode(df)` 会**静默**返回未编码的原表, +不报错、不告警,是最难查的那类错。 + +实测(本机 pandas 3.0.6): + +| 写法 | 选中 | 告警 | +|---|---|---| +| `include=['object', 'category']` | ✅ | **1 条** | +| `include=['object', 'category', 'str', 'string']` | ✅ | 0 | +| `include=['str', 'string']` | ✅ | 0 | + +修法:把 `'str'` / `'string'` 显式写进 `include`(这也是 pandas 官方迁移指南推荐的写法, +同时兼容 pandas 2 与 3)。配套测试用 `warnings.catch_warnings(record=True)` 断言 +**一条告警都不该有**,这样以后 pandas 再收紧行为时会立刻变红,不会静默。 + +**一句诊断口诀**:`select_dtypes` 选不中东西时,先 `print(df[col].dtype)` 看真实 dtype, +别照着记忆里的 `object` 写。 + --- --- diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index 8528e0c..27a2ed2 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -128,3 +128,17 @@ 把该收窄的地方一次列全,别改完第一处就收手; 并且以测试信号(XFAIL → XPASS)为准,别以"我改的那一行看起来对了"为准。 - 首次:2026-10-02|复发:0 + +### 9. 实现写完了,测试还挂着 skip —— 绿灯是假的 + +- 现象:`transform.py` 四个函数都写完了,`pytest` 一片绿(`64 passed`), + 但 `test_normalize_minmax_range` / `test_standardize_has_zero_mean_unit_std` 两条 + 还挂着 `@pytest.mark.skip(reason=_NOT_IMPLEMENTED)` —— 新代码**一条测试都没跑到**。 + 顺带还漏掉一个真错:`normalize` 少了兄弟函数都有的类型守卫,`normalize(None)` 静默返回 NaN。 +- 为什么会错:`skip` 是"还没写"的意思,写完实现后该由**写的人**把它摘掉; + 但摘 mark 这个动作不在"写函数"的肌肉记忆里,跑一次 `pytest` 看到全绿就以为收工了。 + 这是决策 26 埋下的反面:逐条 skip 比模块级 skip 精确,但**数量得自己盯**。 +- 以后怎么防:每次写完实现,用 `pytest -q -rs`(或 `--collect-only`)**核对 skip 条数掉没掉**; + 条数没变就说明实现没被验证过。另一个信号是 `pytest --collect-only` 的收集数 —— + 新写的测试没进收集列表,就是名字或位置不对。 +- 首次:2026-10-02|复发:0 diff --git a/src/mcm_toolkit/data/transform.py b/src/mcm_toolkit/data/transform.py index b6f7b1f..5fcfe71 100644 --- a/src/mcm_toolkit/data/transform.py +++ b/src/mcm_toolkit/data/transform.py @@ -10,6 +10,9 @@ from __future__ import annotations from typing import Any +import numpy as np +import pandas as pd + __all__ = ["log_transform", "normalize", "one_hot_encode", "standardize"] @@ -33,14 +36,53 @@ def normalize(x: Any, method: str = "minmax", feature_range: tuple[float, float] >>> normalize([1, 2, 3]).tolist() [0.0, 0.5, 1.0] """ - # TODO(L0) - raise NotImplementedError + supported_method = ['minmax','vector','max'] + if method not in supported_method: + raise ValueError(f"不支持的方法:{method},仅支持{supported_method}") + + if method == 'minmax': + if not isinstance(feature_range, tuple) or len(feature_range) != 2: + raise TypeError(f"feature_range必须是形如(low, high)的二元组,你传入了{feature_range}") + if feature_range[0] >= feature_range[1]: + raise ValueError(f"feature_range的下界必须小于上界,你传入了{feature_range}") + + arr = np.asarray(x,dtype=float) + + if method == 'minmax': + x_min = np.min(arr, axis=0) + x_max = np.max(arr, axis=0) + + x_range = x_max - x_min + x_range = np.where(x_range == 0, 1.0, x_range) + + scaled = (arr - x_min) / x_range + return scaled * (feature_range[1] - feature_range[0]) + feature_range[0] + + if method == 'vector': + norm = np.linalg.norm(arr, axis=0) + norm = np.where(norm == 0, 1.0, norm) + return arr / norm + + # 上面已校验 method 合法,所以这里不必再写 elif,兜住仅剩的 'max' + x_max = np.max(arr, axis=0) + x_max = np.where(x_max == 0, 1.0, x_max) + return arr / x_max def standardize(x: Any, ddof: int = 0) -> Any: """标准化 `(x - mean) / std`,输出均值 0、标准差 1。""" - # TODO(L0) - raise NotImplementedError + if not isinstance(x, (int, float, list, tuple, np.ndarray, pd.Series)): + raise TypeError(f"输入必须为数值型、列表、numpy array 或 pandas Series,你传入了{type(x)}") + + arr = np.asarray(x, dtype=float) + + mean = np.mean(arr, axis=0) + + std = np.std(arr, axis=0, ddof=ddof) + + std = np.where(std == 0, 1.0, std) + + return (arr - mean) / std def log_transform(x: Any, offset: float | None = None) -> Any: @@ -48,11 +90,33 @@ def log_transform(x: Any, offset: float | None = None) -> Any: `offset=None` 时自动取 `1 - min(x)`(含 0 或负值时)保证定义域。 """ - # TODO(L1) - raise NotImplementedError + if not isinstance(x, (int, float, list, tuple, np.ndarray, pd.Series)): + raise TypeError(f"输入必须为数值型、列表、numpy array 或 pandas Series,你传入了{type(x)}") + + arr = np.asarray(x, dtype=float) + + if offset is None: + min_val = np.min(arr) + offset = 1.0 - min_val if min_val <= 0 else 0.0 + return np.log(arr + offset) def one_hot_encode(data: Any, columns: list[str] | None = None, drop_first: bool = False) -> Any: """类别变量独热编码。""" - # TODO(L1) - raise NotImplementedError + if not isinstance(data, pd.DataFrame): + raise TypeError(f"data必须是 pandas DataFrame, 你传入了{type(data)}") + + if columns is not None: + missing_cols = [col for col in columns if col not in data.columns] + if missing_cols: + raise ValueError(f"指定的列不存在于数据中:{missing_cols}") + cols_to_encode = columns + else: + # 'str' / 'string' 必须显式写上:pandas 3 起字符串列的 dtype 是 'str', + # 只写 'object' 目前还能选中,但会发 Pandas4Warning,且官方声明未来移除该行为 + # —— 到那天这里会**静默**一列都不编码,比报错难查得多。 + object_like = ['object', 'category', 'str', 'string'] + cols_to_encode = data.select_dtypes(include=object_like).columns.tolist() + + return pd.get_dummies(data, columns=cols_to_encode, drop_first=drop_first) + diff --git a/tests/test_data.py b/tests/test_data.py index 595ca95..ff99661 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -354,11 +354,11 @@ def test_impute_missing_mode_does_not_mutate_input(): assert bool(df["a"].isna().any()) -# ------------------------------------------------- 以下为未实现算法(L0/L1 待写) +# ------------------------------------------------------ data: transform -@pytest.mark.skip(reason=_NOT_IMPLEMENTED) def test_normalize_minmax_range(rng): + """minmax 是**按列**压到 [0, 1],不是整表压一次。""" from mcm_toolkit.data import normalize x = rng.normal(size=(50, 3)) @@ -367,7 +367,54 @@ def test_normalize_minmax_range(rng): assert np.allclose(out.max(axis=0), 1.0) -@pytest.mark.skip(reason=_NOT_IMPLEMENTED) +def test_normalize_minmax_doctest_values(): + """docstring 里那个 Examples 就是规格,不能只有形似。""" + from mcm_toolkit.data import normalize + + assert normalize([1, 2, 3]).tolist() == [0.0, 0.5, 1.0] + + +def test_normalize_respects_feature_range(): + from mcm_toolkit.data import normalize + + out = np.asarray(normalize([1.0, 2.0, 3.0], feature_range=(-1.0, 1.0))) + assert np.allclose(out, [-1.0, 0.0, 1.0]) + + +def test_normalize_constant_column_stays_finite(): + """常量列 max == min,不能出 nan / inf;固定压到 feature_range 的下界。""" + from mcm_toolkit.data import normalize + + out = np.asarray(normalize([[2.0, 1.0], [2.0, 3.0], [2.0, 5.0]])) + assert np.allclose(out[:, 0], 0.0) + assert bool(np.isfinite(out).all()) + + +def test_normalize_vector_method_gives_unit_columns(): + from mcm_toolkit.data import normalize + + out = np.asarray(normalize([[3.0, 0.0], [4.0, 0.0]], method="vector")) + assert np.allclose(np.linalg.norm(out, axis=0), [1.0, 0.0]) + + +def test_normalize_max_method_scales_by_column_max(): + from mcm_toolkit.data import normalize + + out = np.asarray(normalize([[1.0, 10.0], [2.0, 20.0]], method="max")) + assert np.allclose(out, [[0.5, 0.5], [1.0, 1.0]]) + + +def test_normalize_rejects_bad_method_and_feature_range(): + from mcm_toolkit.data import normalize + + with pytest.raises(ValueError, match="bogus"): + normalize([1.0, 2.0], method="bogus") + with pytest.raises(ValueError, match="feature_range"): + normalize([1.0, 2.0], feature_range=(1.0, 0.0)) + with pytest.raises(TypeError, match="feature_range"): + normalize([1.0, 2.0], feature_range=(0.0, 1.0, 2.0)) # type: ignore[arg-type] + + def test_standardize_has_zero_mean_unit_std(rng): from mcm_toolkit.data import standardize @@ -377,6 +424,106 @@ def test_standardize_has_zero_mean_unit_std(rng): assert abs(out.std() - 1.0) < 1e-9 +def test_standardize_is_per_column(rng): + """按列标准化:每列各自均值 0、标准差 1。""" + from mcm_toolkit.data import standardize + + x = rng.normal(size=(200, 3)) * [1.0, 10.0, 100.0] + [0.0, 50.0, -3.0] + out = np.asarray(standardize(x)) + assert np.allclose(out.mean(axis=0), 0.0, atol=1e-12) + assert np.allclose(out.std(axis=0), 1.0, atol=1e-12) + + +def test_standardize_honours_ddof(): + """ddof 要真的传下去:ddof=0 才得到单位方差;ddof=1 时结果的 std = sqrt((n-1)/n)。 + + 除以更大的样本标准差,结果自然更小 —— 所以 ddof=1 时 out.std() **小于** 1。 + """ + from mcm_toolkit.data import standardize + + x = np.array([1.0, 2.0, 3.0, 4.0]) + assert np.allclose(standardize(x, ddof=0).std(), 1.0) + assert np.allclose(standardize(x, ddof=1).std(), np.sqrt(3.0 / 4.0)) + + +def test_standardize_constant_column_stays_finite(): + from mcm_toolkit.data import standardize + + out = np.asarray(standardize([[2.0, 1.0], [2.0, 3.0]])) + assert np.allclose(out[:, 0], 0.0) + assert bool(np.isfinite(out).all()) + + +def test_standardize_rejects_bad_type(): + from mcm_toolkit.data import standardize + + with pytest.raises(TypeError): + standardize("abc") + + +def test_log_transform_shifts_to_keep_domain(): + """含 0 或负值时自动取 offset = 1 - min(x),保证 log 的定义域。""" + from mcm_toolkit.data import log_transform + + x = np.array([-5.0, 0.0, 5.0]) + out = np.asarray(log_transform(x)) + assert bool(np.isfinite(out).all()) + assert np.allclose(out, np.log(x + 6.0)) + + +def test_log_transform_positive_data_needs_no_offset(): + from mcm_toolkit.data import log_transform + + assert np.allclose(log_transform([1.0, np.e]), [0.0, 1.0]) + + +def test_log_transform_rejects_bad_type(): + from mcm_toolkit.data import log_transform + + with pytest.raises(TypeError): + log_transform("abc") + + +def test_one_hot_encode_auto_detects_text_columns_quietly(): + """pandas 3 起字符串列的 dtype 是 'str':要能选中,且不能走已弃用的 select_dtypes 路径。""" + import warnings + + from mcm_toolkit.data import one_hot_encode + + df = pd.DataFrame({"city": ["A", "B", "C"], "v": [1.0, 2.0, 3.0]}) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + out = one_hot_encode(df) + + messages = [str(w.message) for w in caught] + assert not messages, f"自动识别不该产生任何告警,实际:{messages}" + assert "city_A" in out.columns + assert "v" in out.columns + + +def test_one_hot_encode_explicit_columns_and_drop_first(): + from mcm_toolkit.data import one_hot_encode + + df = pd.DataFrame({"city": ["A", "B", "C"], "v": [1.0, 2.0, 3.0]}) + out = one_hot_encode(df, columns=["city"], drop_first=True) + assert "city_A" not in out.columns + assert "city_B" in out.columns + assert "city_C" in out.columns + + +def test_one_hot_encode_rejects_missing_column_and_non_frame(): + from mcm_toolkit.data import one_hot_encode + + df = pd.DataFrame({"city": ["A"], "v": [1.0]}) + with pytest.raises(ValueError, match="nope"): + one_hot_encode(df, columns=["nope"]) + with pytest.raises(TypeError): + one_hot_encode([1, 2, 3]) + + +# ------------------------------------------------- 以下为未实现算法(L0/L1 待写) + + @pytest.mark.skip(reason=_NOT_IMPLEMENTED) def test_train_test_split_sizes_and_no_overlap(rng): from mcm_toolkit.data import train_test_split