feat(data): transform.py 四个函数落地;补 18 条测试、清 5 条 ruff

实现(用户本人写):
- normalize(minmax / vector / max,三种都按列)+ feature_range 校验 + 常量列不出 nan
- standardize(按列,ddof 真的传下去)+ 类型守卫
- log_transform(offset=None 时自动取 1 - min(x),保证 x + offset >= 1)+ 类型守卫
- one_hot_encode(自动识别对象列 / 指定列 / drop_first)
- data/ 下的 L0 至此结清(L0 7/12)

Codex 的轻度修复(不改接口与语义):
- 清掉 5 条 ruff:RET503 / F541 / RET504 / RET505 / SIM108
- supported_method 与 feature_range 的校验提到 np.asarray 之前(守卫集中开头)
- 修掉说反的报错文案(条件是要求下界 < 上界,原文写"上界必须小于下界")
- one_hot_encode 自动识别时显式加上 str / string:只写 object 在 pandas 3 上是弃用路径,
  每次调用发 Pandas4Warning,未来会变成"一列都选不中"的静默错误(详见坑 9)

测试:
- 新写 18 条(transform 段),其中两条是把原先挂着的 skip 转为实跑 ——
  normalize / standardize 的实现此前一条测试都没跑到(详见个人易错点 9)
- 82 passed / 40 skipped / 0 xfailed;ruff 0;pyright 0

文档:
- STATUS.md:E 节 + 验证表(122 条用例分布)+ 下一步重写(transform 待定的 4 个口径)+ 决策 34
- docs/04-environment.md:新增坑 9(pandas 3 的 str dtype 与 select_dtypes 弃用路径,含实测对照表)
- docs/dev/personal-pitfalls.md:只追加第 9 条(实现写完但测试还挂 skip,绿灯是假的)
- CHANGELOG.md:本轮变更 + 记录 transform 待定的 4 个口径
This commit is contained in:
guzhujushi committed 2026-10-02 20:43:03 +08:00
1 parent 5749e50e83
commit 6eab0babde
6 files changed
+330 -32

No files matched your search

+24
View File
@@ -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 条。
- 已知缺口:
+39 -21
View File
@@ -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)走完整流程,反向沉淀函数。
+31
View File
@@ -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` 写。
---
---
+14
View File
@@ -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
+72 -8
View File
@@ -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)
+150 -3
View File
@@ -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