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:
1 parent
5749e50e83
commit
6eab0babde
6 files changed
+330
-32
No files matched your search
@@ -33,6 +33,11 @@
|
|||||||
- `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` /
|
- `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` /
|
||||||
`impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测跑通(exit 0);
|
`impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测跑通(exit 0);
|
||||||
末尾留 `TODO(L0)` 等 `stats.describe`。
|
末尾留 `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
|
### Changed
|
||||||
- `core.get_rng` / `core.set_seed`:实现(L0 第 2 项)。`get_rng` 收 `int` / `np.integer` /
|
- `core.get_rng` / `core.set_seed`:实现(L0 第 2 项)。`get_rng` 收 `int` / `np.integer` /
|
||||||
@@ -105,6 +110,19 @@
|
|||||||
- `tests/test_data.py`:摘掉最后一条 `xfail`(mode 就地改)并加强断言;新增 3 条
|
- `tests/test_data.py`:摘掉最后一条 `xfail`(mode 就地改)并加强断言;新增 3 条
|
||||||
(非法类型抛 `TypeError`、元组按一维数组型处理、全 NaN 列与单元素列不抛异常)。
|
(非法类型抛 `TypeError`、元组按一维数组型处理、全 NaN 列与单元素列不抛异常)。
|
||||||
测试 **60 passed / 1 xfailed → 64 passed / 0 xfailed**。
|
测试 **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
|
### Notes
|
||||||
@@ -112,6 +130,12 @@
|
|||||||
`clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、
|
`clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、
|
||||||
非数值列恒 `False`(决策 32)、入参类型校验补上(决策 33)。
|
非数值列恒 `False`(决策 32)、入参类型校验补上(决策 33)。
|
||||||
S 档卡片与 `examples/` 用法也已落地 —— `detect_outliers` 这条线**结清**。
|
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`,
|
- 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `stats` / `viz`,
|
||||||
`Result` 的用法届时并进去。见 `STATUS.md`「下一步」第 3 条。
|
`Result` 的用法届时并进去。见 `STATUS.md`「下一步」第 3 条。
|
||||||
- 已知缺口:
|
- 已知缺口:
|
||||||
|
|||||||
@@ -112,14 +112,26 @@
|
|||||||
接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`,`dict` 不再接受(决策 33)
|
接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`,`dict` 不再接受(决策 33)
|
||||||
- [x] `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` /
|
- [x] `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` /
|
||||||
`impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测可直接跑通
|
`impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测可直接跑通
|
||||||
- [ ] 剩余 7 项:`normalize` `standardize` `describe` `correlation_matrix`
|
- [x] `data/transform.py` 的四个函数(**由用户本人实现**):`normalize` / `standardize`(L0 第 6、7 项)、
|
||||||
`bootstrap_ci` `monte_carlo` `save_figure`
|
`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` |
|
| 检查 | 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 项 |
|
| `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
|
||||||
| `ruff check` | All checks passed | **All checks passed**(0 error) |
|
| `ruff check` | All checks passed | **All checks passed**(0 error) |
|
||||||
| `scripts/doctor.ps1` | 跑通 | — |
|
| `scripts/doctor.ps1` | 跑通 | — |
|
||||||
@@ -129,11 +141,13 @@
|
|||||||
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
|
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
|
||||||
`docs/04-environment.md` 坑 7)
|
`docs/04-environment.md` 坑 7)
|
||||||
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
|
- 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、实现没跟上"的用例全部转正,
|
- **`xfail` 清零**:累计 7 条"契约已写进 docstring、实现没跟上"的用例全部转正,
|
||||||
决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环
|
决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环
|
||||||
- `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz`
|
- `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 语法解析
|
- 三个 `.ps1` 通过 PowerShell AST 语法解析
|
||||||
|
|
||||||
### ⚠️ 需要注意 / 遗留
|
### ⚠️ 需要注意 / 遗留
|
||||||
@@ -143,8 +157,9 @@
|
|||||||
只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。
|
只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。
|
||||||
- [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。
|
- [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。
|
||||||
note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。
|
note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。
|
||||||
- [ ] L0 完成 **5/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
|
- [ ] L0 完成 **7/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
|
||||||
`detect_outliers`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进;详情见上面 E 节
|
`detect_outliers`、`normalize`、`standardize`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进;
|
||||||
|
详情见上面 E 节
|
||||||
- [ ] 本机 `exec_command` 在沙箱模式下**完全不可用**
|
- [ ] 本机 `exec_command` 在沙箱模式下**完全不可用**
|
||||||
(`helper_unknown_error: setup refresh had errors`),只有提权
|
(`helper_unknown_error: setup refresh had errors`),只有提权
|
||||||
(`require_escalated`)模式可用。沙箱模式只允许写 `D:\MCM&ICM_Toolkit`。
|
(`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 不失败,不会卡住提交 |
|
| 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 认的转发手段 |
|
| 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 |
|
| 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` 同等对待 |
|
| 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` 的读法是"这一列没有离群点",对文本列是最不坏的解释,也让整表能一次跑完 |
|
| 32 | `detect_outliers` 对非数值列恒返回 `False`(既不跳过也不报错) | **用户本人在两条路里选的**。跳过会让 docstring 里"与原数据同形"变成谎话(形状对不上,没法按位置对回原表);报错则让一张带 `city` 列的真题表直接不可用。`False` 的读法是"这一列没有离群点",对文本列是最不坏的解释,也让整表能一次跑完 |
|
||||||
| 31 | Codex 可**直接改** `src/` 的范围:纯 bug(不改接口与语义)、格式、死代码 | 决策 25 说实现归用户,但"能跑"是底线:`winsorize(pd.Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩、测试变红,这种必须当场修。凡是要**选一个语义**的(文本列返回什么、mode 要不要就地改)一律留给用户本人,Codex 只把选项和影响列清楚 |
|
| 31 | Codex 可**直接改** `src/` 的范围:纯 bug(不改接口与语义)、格式、死代码 | 决策 25 说实现归用户,但"能跑"是底线:`winsorize(pd.Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩、测试变红,这种必须当场修。凡是要**选一个语义**的(文本列返回什么、mode 要不要就地改)一律留给用户本人,Codex 只把选项和影响列清楚 |
|
||||||
@@ -195,17 +211,19 @@
|
|||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
1. **`data/transform.py` 的 `normalize` / `standardize`**(用户本人开始写)
|
1. **`transform.py` 还剩 4 个口径要你拍板**(都要落到 docstring 的 `Raises` / `Notes` 里):
|
||||||
- 签名已经定好(`normalize(x, method="minmax", feature_range=(0.0, 1.0))`、
|
- **入参类型策略三个函数不一致**:`standardize` / `log_transform` 有守卫,只收
|
||||||
`standardize(x, ddof=0)`),动手前只需要定两件事:`normalize` 的 `method` 取值集合
|
`int/float/list/tuple/ndarray/Series`;`normalize` 没有,全靠 `np.asarray` 自己崩 ——
|
||||||
(默认 `"minmax"`;要不要再放 `"maxabs"` / `"robust"`?YAGNI 的话就只留 `"minmax"`)、
|
`normalize('abc')` 抛 numpy 的 `ValueError`(不是 §4.7 要的 `TypeError`),
|
||||||
以及 `axis` 语义 —— 赛题里几乎总是**按列**,那就明确默认按列,别留模糊。
|
`normalize(None)` 更糟:**静默返回 NaN**。建议照 `standardize` 的元组抄一份守卫,三处统一。
|
||||||
- 注意 `standardize` 的 `ddof=0` 与 `detect_outliers` 里 pandas 默认的 `ddof=1` 不一致,
|
- **要不要收 `DataFrame`**:`normalize(df)` 现在能跑,但返回 ndarray、**列名丢了**;
|
||||||
两处都要在 docstring 里写明,否则以后对不上账(见 `docs/algorithms/detect_outliers.md` §5)。
|
`standardize(df)` 直接 `TypeError`。`load_table` 出来的就是 DataFrame,
|
||||||
- `tests/test_data.py` 里两条 skip 已就位(`test_normalize_minmax_range`、
|
建议三个函数都收 DataFrame、按列处理、并保持 DataFrame 返回(列名留着才好回填原表)。
|
||||||
`test_standardize_has_zero_mean_unit_std`)。
|
- **显式 `offset` 没校验**:`log_transform(x, offset=-10.0)` 静默返回 NaN(`np.log` 负数 + RuntimeWarning)。
|
||||||
2. `stats.describe` + `data/transform.py` 的 `log_transform` / `one_hot_encode`
|
要么校验(`x + offset <= 0` 就抛 `ValueError`),要么在 docstring 里写清"由调用方保证"。
|
||||||
(`examples/01_clean_and_describe.py` 末尾的 `TODO(L0)` 就等 `describe`)。
|
- **常量列行为写进 docstring**:行为已被测试钉住(决策 34),但 docstring 还是空白。
|
||||||
3. 补齐 `examples/00_quickstart.py`:它要等 `stats` / `viz`;`Result` 的用法届时一并写进去。
|
2. 四个函数的 docstring 都缺 `Parameters` / `Returns` / `Raises`(§4.7 硬要求)。
|
||||||
4. 每写完一个函数:docstring(§4.7 全套)+ 一条 pytest + `examples/` 一行用法 + 更新 `STATUS.md`。
|
上面 4 点定了之后一次补齐 —— 现在补也会因为口径未定而要返工。
|
||||||
5. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
3. `stats.describe` + `stats` 其余函数:`examples/00_quickstart.py` 与
|
||||||
|
`examples/01_clean_and_describe.py` 末尾的 `TODO(L0)` 都在等它。
|
||||||
|
4. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
||||||
@@ -271,6 +271,37 @@ numeric = cast(pd.DataFrame, data[numeric_cols])
|
|||||||
**一句诊断口诀**:pyright 说某行"属性不存在"、但运行时确实能跑,先 `reveal_type(...)`
|
**一句诊断口诀**:pyright 说某行"属性不存在"、但运行时确实能跑,先 `reveal_type(...)`
|
||||||
把真实推断打出来,看并集里混进了什么,别急着加 `# type: ignore`。
|
把真实推断打出来,看并集里混进了什么,别急着加 `# 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` 写。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -128,3 +128,17 @@
|
|||||||
把该收窄的地方一次列全,别改完第一处就收手;
|
把该收窄的地方一次列全,别改完第一处就收手;
|
||||||
并且以测试信号(XFAIL → XPASS)为准,别以"我改的那一行看起来对了"为准。
|
并且以测试信号(XFAIL → XPASS)为准,别以"我改的那一行看起来对了"为准。
|
||||||
- 首次:2026-10-02|复发:0
|
- 首次: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
|
||||||
@@ -10,6 +10,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
__all__ = ["log_transform", "normalize", "one_hot_encode", "standardize"]
|
__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()
|
>>> normalize([1, 2, 3]).tolist()
|
||||||
[0.0, 0.5, 1.0]
|
[0.0, 0.5, 1.0]
|
||||||
"""
|
"""
|
||||||
# TODO(L0)
|
supported_method = ['minmax','vector','max']
|
||||||
raise NotImplementedError
|
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:
|
def standardize(x: Any, ddof: int = 0) -> Any:
|
||||||
"""标准化 `(x - mean) / std`,输出均值 0、标准差 1。"""
|
"""标准化 `(x - mean) / std`,输出均值 0、标准差 1。"""
|
||||||
# TODO(L0)
|
if not isinstance(x, (int, float, list, tuple, np.ndarray, pd.Series)):
|
||||||
raise NotImplementedError
|
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:
|
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 或负值时)保证定义域。
|
`offset=None` 时自动取 `1 - min(x)`(含 0 或负值时)保证定义域。
|
||||||
"""
|
"""
|
||||||
# TODO(L1)
|
if not isinstance(x, (int, float, list, tuple, np.ndarray, pd.Series)):
|
||||||
raise NotImplementedError
|
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:
|
def one_hot_encode(data: Any, columns: list[str] | None = None, drop_first: bool = False) -> Any:
|
||||||
"""类别变量独热编码。"""
|
"""类别变量独热编码。"""
|
||||||
# TODO(L1)
|
if not isinstance(data, pd.DataFrame):
|
||||||
raise NotImplementedError
|
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
@@ -354,11 +354,11 @@ def test_impute_missing_mode_does_not_mutate_input():
|
|||||||
assert bool(df["a"].isna().any())
|
assert bool(df["a"].isna().any())
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------- 以下为未实现算法(L0/L1 待写)
|
# ------------------------------------------------------ data: transform
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
|
||||||
def test_normalize_minmax_range(rng):
|
def test_normalize_minmax_range(rng):
|
||||||
|
"""minmax 是**按列**压到 [0, 1],不是整表压一次。"""
|
||||||
from mcm_toolkit.data import normalize
|
from mcm_toolkit.data import normalize
|
||||||
|
|
||||||
x = rng.normal(size=(50, 3))
|
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)
|
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):
|
def test_standardize_has_zero_mean_unit_std(rng):
|
||||||
from mcm_toolkit.data import standardize
|
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
|
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)
|
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
||||||
def test_train_test_split_sizes_and_no_overlap(rng):
|
def test_train_test_split_sizes_and_no_overlap(rng):
|
||||||
from mcm_toolkit.data import train_test_split
|
from mcm_toolkit.data import train_test_split
|
||||||
|
|||||||
Reference in new issue
Block a user