fix(data): clean.py 轻度修复 + 摘掉 5 条已修好的 xfail
Codex 直接改的(格式 / 死代码 / 纯 bug,不动接口与语义): - winsorize(Series) 直接崩:select_dtypes 在 Series 上不存在,Series 分支提到最前并加数值 dtype 守卫 - winsorize 的分位数改为只在数值列上算,含文本列的赛题表不再崩(与已有 numeric_cols 判断对齐) - 删掉 detect_outliers 里已成空操作的 df = pd.DataFrame(data),去掉 df 别名 - clean_missing 的嵌套 if 合并(ruff SIM102),清掉 W293 行尾空白与函数内连续空行 测试: - 5 条 xfail 经 XPASS 信号确认已修好,摘掉 mark 转正式用例 (detect_outliers 4 条 + winsorize 混合类型 1 条) - 53 passed / 7 xfailed -> 58 passed / 2 xfailed - 剩 2 条 xfail 保留,且不是漏改而是设计没定: detect_outliers 遇文本列返回什么、impute_missing 的 mode 分支要不要就地改调用方的表 - ruff: 1 error -> All checks passed;pyright: 0 errors 不变 文档: - STATUS.md:L0 进度 4/12、验证结果表、下一步重写、新增决策 31 (Codex 可直接改 src 的范围:纯 bug / 格式 / 死代码;要选语义的留给用户) - CHANGELOG.md:本轮变更,并修掉 [Unreleased] 里被重复追加撑坏的一句 - docs/dev/personal-pitfalls.md:只追加第 7 条(守卫子句要排在类型分派之后)
This commit is contained in:
1 parent
c2595b6d07
commit
319b16b17c
5 files changed
+89
-66
No files matched your search
+19
-7
@@ -52,7 +52,7 @@
|
|||||||
(Codex 代写,与 io.py 同一套 numpydoc 格式)。
|
(Codex 代写,与 io.py 同一套 numpydoc 格式)。
|
||||||
- `data.clean`:ruff 从 6 条清到 1 条——导入分组(I001)、elif 链改 if 加末尾无条件 return
|
- `data.clean`:ruff 从 6 条清到 1 条——导入分组(I001)、elif 链改 if 加末尾无条件 return
|
||||||
(RET505 / RET503)、去掉多余 f 前缀(F541)、删掉只会被覆盖的 mask 初始化(死代码)、
|
(RET505 / RET503)、去掉多余 f 前缀(F541)、删掉只会被覆盖的 mask 初始化(死代码)、
|
||||||
去掉行尾空白(W293)。剩下的 1 条 SIM102 就在下面 Notes 那条待修的代码里,删掉它即全绿。:分析器会去 PATH 上找 python,
|
去掉行尾空白(W293)。
|
||||||
本机 PATH 上只有 Codex 运行时的 3.14 shim(无科学栈),且 pyright 不认
|
本机 PATH 上只有 Codex 运行时的 3.14 shim(无科学栈),且 pyright 不认
|
||||||
`pyvenv.cfg` 的 `include-system-site-packages`。
|
`pyvenv.cfg` 的 `include-system-site-packages`。
|
||||||
改为 `[tool.pyright]` 指定 `venvPath = "."` / `venv = ".venv"` /
|
改为 `[tool.pyright]` 指定 `venvPath = "."` / `venv = ".venv"` /
|
||||||
@@ -71,15 +71,27 @@
|
|||||||
- conda base 里 zstandard 的 `*.conda_trash` 残骸目录被 Python 当成 namespace package 导入,
|
- conda base 里 zstandard 的 `*.conda_trash` 残骸目录被 Python 当成 namespace package 导入,
|
||||||
使 `.venv`(= base)的 `pd.read_csv` 抛 `ImportError: Can't determine version for zstandard`。
|
使 `.venv`(= base)的 `pd.read_csv` 抛 `ImportError: Can't determine version for zstandard`。
|
||||||
已把残骸目录改名禁用(**可逆**,未删除),详见 `docs/04-environment.md` 坑 6。
|
已把残骸目录改名禁用(**可逆**,未删除),详见 `docs/04-environment.md` 坑 6。
|
||||||
|
- `data.clean` 第二轮审查(2026-10-02),Codex 直接修掉 4 处**轻度**问题:
|
||||||
|
`winsorize(Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩(Series 分支提到最前,
|
||||||
|
并加数值 dtype 守卫);`winsorize` 的 `data.quantile()` 改为只在数值列上算
|
||||||
|
(文本列不再崩,与已有的 `numeric_cols` 判断对齐);删掉 `detect_outliers` 里已成空操作的
|
||||||
|
`df = pd.DataFrame(data)`(数据在更早处就转过了,顺手去掉 `df` 这个别名);
|
||||||
|
清掉行尾空白(W293)、函数体内的连续空行,并把 `clean_missing` 的嵌套 `if` 合并
|
||||||
|
(SIM102,ruff 最后 1 条)。ruff `1 error` → **`All checks passed`**,pyright 仍 0。
|
||||||
|
- `tests/test_data.py`:5 条 `xfail` 经 XPASS 信号确认已修好,**摘掉 mark 转正式用例**
|
||||||
|
(`detect_outliers` 的 4 条 + `winsorize` 混合类型 1 条)。测试 **53 passed / 7 xfailed →
|
||||||
|
58 passed / 2 xfailed**。
|
||||||
|
- `CHANGELOG.md`:[Unreleased] 里有一句被重复追加撑坏
|
||||||
|
("…删掉它即全绿。:分析器会去 PATH 上找 python,"),已修。
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- `detect_outliers` **目前不可用**,三个 bug 已在 `tests/test_data.py` 里用 xfail 标出:
|
- `detect_outliers` 原有三个 bug,现已修掉两个(threshold 范围校验挪进 `clean_missing`、
|
||||||
threshold 范围校验放错了函数(iqr 分支 UnboundLocalError、zscore 分支默认 3.0 被 0<=t<=1 拒掉)、
|
`is_series` / `is_ld_array` 提到类型转换**之前**),**只剩"文本列"这一处**:
|
||||||
quantile 在含文本列的表上直接崩、is_series / is_ld_array 在数据已转成 DataFrame 之后才判断
|
`data.quantile()` 仍在全表上算,含文本列的赛题表一带就崩。
|
||||||
因此永远为 False(返回形状仍是 (n,1),与 docstring 承诺的「与原数据同形」不符)。
|
这一处要**先定设计**(非数值列返回全 `False`,还是只对数值列给掩码),
|
||||||
修法与优先级见 `STATUS.md` 的「下一步」。
|
所以不在"轻度直接修"的范围内,留给用户本人写 —— 见 `STATUS.md`「下一步」第 1 条。
|
||||||
- `winsorize` 与 `detect_outliers` 都还没能在含文本列的赛题表上跑通:分位数必须限制在数值列。
|
|
||||||
- `impute_missing` 的 mode 分支会**就地修改**调用方的 DataFrame,其余四个分支都返回新表。
|
- `impute_missing` 的 mode 分支会**就地修改**调用方的 DataFrame,其余四个分支都返回新表。
|
||||||
|
同样属于"要选语义",留给用户本人写 —— 见 `STATUS.md`「下一步」第 3 条。
|
||||||
- 已知缺口:
|
- 已知缺口:
|
||||||
- 除 `core.Result`、`core.get_rng`、`core.set_seed`、`data.load_table`、`data.save_table` 外,其余函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。
|
- 除 `core.Result`、`core.get_rng`、`core.set_seed`、`data.load_table`、`data.save_table` 外,其余函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。
|
||||||
这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。
|
这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# STATUS — 当前进度
|
# STATUS — 当前进度
|
||||||
|
|
||||||
> 更新日期:2026-10-01
|
> 更新日期:2026-10-02
|
||||||
|
|
||||||
## 本次任务
|
## 本次任务
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
**第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**,
|
**第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**,
|
||||||
用户本人写 `src/`、Codex 负责审查 + `tests/`(决策 25)。
|
用户本人写 `src/`、Codex 负责审查 + `tests/`(决策 25)。
|
||||||
当前 3/12:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`。
|
当前 4/12:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`。
|
||||||
|
|
||||||
## 任务清单
|
## 任务清单
|
||||||
|
|
||||||
@@ -95,18 +95,17 @@
|
|||||||
- `threshold` 用 `math.ceil` 算 `min_non_na`:3 列、阈值 0.5 时必须是 2,
|
- `threshold` 用 `math.ceil` 算 `min_non_na`:3 列、阈值 0.5 时必须是 2,
|
||||||
写 `int()` 截断会漏删
|
写 `int()` 截断会漏删
|
||||||
- 顺手落地兄弟函数 `impute_missing` / `winsorize`(原属 L1)
|
- 顺手落地兄弟函数 `impute_missing` / `winsorize`(原属 L1)
|
||||||
- [ ] **待修:** `detect_outliers` 已写但有 3 个 bug(见下)
|
- [ ] **待收尾:** `detect_outliers` 只剩"文本列"1 处,且要先定设计(见「下一步」第 1 条)
|
||||||
- [ ] 剩余 7 项:`normalize`
|
- [ ] 剩余 8 项:`detect_outliers`(收尾)+ `normalize` `standardize` `describe`
|
||||||
`detect_outliers` `normalize` `standardize` `describe` `correlation_matrix`
|
`correlation_matrix` `bootstrap_ci` `monte_carlo` `save_figure`
|
||||||
`bootstrap_ci` `monte_carlo` `save_figure`
|
|
||||||
|
|
||||||
### 验证结果
|
### 验证结果
|
||||||
|
|
||||||
| 检查 | venv | conda `mcm-toolkit` |
|
| 检查 | venv | conda `mcm-toolkit` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `pytest` | 见下(单列) | **53 passed, 42 skipped, 7 xfailed** |
|
| `pytest` | 见下(单列) | **58 passed, 42 skipped, 2 xfailed** |
|
||||||
| `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 | **1 error**(就在 `detect_outliers` 那段待修的代码里) |
|
| `ruff check` | All checks passed | **All checks passed**(0 error) |
|
||||||
| `scripts/doctor.ps1` | 跑通 | — |
|
| `scripts/doctor.ps1` | 跑通 | — |
|
||||||
| SALib / pymoo / pyright | 缺失 | 已装 |
|
| SALib / pymoo / pyright | 缺失 | 已装 |
|
||||||
|
|
||||||
@@ -114,10 +113,10 @@
|
|||||||
(修配置之前是 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(与两套环境一致)
|
||||||
- 53 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 22 条 `data` 行为测试
|
- 58 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 27 条 `data` 行为测试
|
||||||
- 7 条 `xfail` 是**已知待修**的契约(`detect_outliers` 的 4 条、`winsorize` 混合类型、
|
- 2 条 `xfail` 是**已知待修**的契约:`detect_outliers` 遇文本列、`impute_missing` 的 mode 就地改。
|
||||||
`clean_missing` 阈值范围、`impute_missing` 的 mode 就地修改);
|
两者都**不是漏改,是设计没定**;修好后会自动变 XPASS,那时把 `xfail` 摘掉即可(决策 30)
|
||||||
修好后会自动变 XPASS,那时把 `xfail` 摘掉即可(决策 30)
|
- 上一轮 5 条 `xfail` 正是靠这个 XPASS 信号确认修好并摘掉的
|
||||||
- 42 条 skip 是等实现的算法(`normalize` / `standardize` / `train_test_split` 等)
|
- 42 条 skip 是等实现的算法(`normalize` / `standardize` / `train_test_split` 等)
|
||||||
- 三个 `.ps1` 通过 PowerShell AST 语法解析
|
- 三个 `.ps1` 通过 PowerShell AST 语法解析
|
||||||
|
|
||||||
@@ -173,26 +172,22 @@
|
|||||||
| 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 |
|
||||||
|
| 31 | Codex 可**直接改** `src/` 的范围:纯 bug(不改接口与语义)、格式、死代码 | 决策 25 说实现归用户,但"能跑"是底线:`winsorize(pd.Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩、测试变红,这种必须当场修。凡是要**选一个语义**的(文本列返回什么、mode 要不要就地改)一律留给用户本人,Codex 只把选项和影响列清楚 |
|
||||||
| 27 | base 里的 zstandard 残骸目录**改名禁用**,不删除、不重装 | 删掉不可逆;`pip install zstandard` 会给 base 塞一个 conda 记录里根本没有的包,下次 `conda update --all` 又要重新判断。改名后 `import` 变回正常 `ModuleNotFoundError`,pandas 自动跳过 |
|
| 27 | base 里的 zstandard 残骸目录**改名禁用**,不删除、不重装 | 删掉不可逆;`pip install zstandard` 会给 base 塞一个 conda 记录里根本没有的包,下次 `conda update --all` 又要重新判断。改名后 `import` 变回正常 `ModuleNotFoundError`,pandas 自动跳过 |
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
1. **修 `detect_outliers`**(唯一挡在 L0 前进路上的 🔴,三处都在 `src/mcm_toolkit/data/clean.py`):
|
1. **收尾 `detect_outliers`**(`src/mcm_toolkit/data/clean.py`,只剩 1 处,且**要先定设计**):
|
||||||
- `clean.py:165-167` 的 `threshold` 范围校验放错了函数:`threshold` 只在 zscore 分支
|
非数值列怎么办 —— 一律返回 `False`,还是只对数值列算掩码?
|
||||||
赋值,iqr 走进去就是 `UnboundLocalError`;而且 z 的阈值本来就是 3.0,
|
现在 iqr / zscore 两条分支都直接在**全表**上 `quantile()` / `mean()`,赛题表一带文本列就崩。
|
||||||
用 `0 <= threshold <= 1` 卡它必然把默认值拒掉。
|
定完把它换成 `data[numeric_cols].quantile(...)`(`winsorize` 已按"只对数值列算"改好,可直接照抄),
|
||||||
你本意多半是想校验 `clean_missing` 的 `threshold`(那个才该是 [0,1])——把它挪过去,
|
并把 `tests/test_data.py::test_detect_outliers_survives_text_columns` 的 xfail 摘掉。
|
||||||
`detect_outliers` 里删掉。ruff 剩下的那 1 条(SIM102)也在这 3 行里,删掉就全绿。
|
|
||||||
- `clean.py:145` 的 `df.quantile()` 要在**数值列**上算:`df.select_dtypes(include='number')`,
|
|
||||||
否则赛题表一带文本列就崩。`winsorize` 的 `clean.py:186` 同病,
|
|
||||||
而且它的空数值列判断排在了分位数之后,等于没兜住。
|
|
||||||
- `clean.py:130-131` 的 `is_series` / `is_ld_array` **永远为 False**:
|
|
||||||
第 114-117 行已经把 `data` 转成 DataFrame 了,再判断 `isinstance(data, pd.Series)` 自然不成立。
|
|
||||||
要在转换**之前**记下来,或者干脆用 `inspect` 拿原始入参。修好后
|
|
||||||
`test_detect_outliers_returns_mask_not_filtered_data` 会从 xfail 变 XPASS。
|
|
||||||
2. **写 `detect_outliers` 的 S 档函数卡片**(`docs/algorithms/` 下,照 `docs/dev/function-card-template.md`)。
|
2. **写 `detect_outliers` 的 S 档函数卡片**(`docs/algorithms/` 下,照 `docs/dev/function-card-template.md`)。
|
||||||
roadmap 要求 S 档先写卡片再写码 —— 这次是反着来的,所以三个 bug 都在"该想清楚"的地方翻车:
|
roadmap 要求 S 档先写卡片再写码 —— 这次是反着来的,所以三个 bug 都在"该想清楚"的地方翻车:
|
||||||
上界符号、布尔优先级、维度。卡片本身就是防这个的。
|
上界符号、布尔优先级、维度。卡片本身就是防这个的。
|
||||||
3. 顺手把 `impute_missing` 的 mode 分支改成**返回新表**(现在就地改调用方的 DataFrame,和另外四个分支不一致)。
|
3. 顺手把 `impute_missing` 的 mode 分支改成**返回新表**
|
||||||
4. 每写完一个函数,在 `examples/` 里加一行最小用法。
|
(现在就地改调用方的 DataFrame,和另外四个分支不一致;`data = data.copy()` 一行的事)。
|
||||||
5. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
4. **下一个文件**:`data/transform.py` 的 `normalize` / `standardize`
|
||||||
|
(`tests/test_data.py` 里两条 skip 已就位;写之前先定 `method` 的取值集合与 `axis` 语义)。
|
||||||
|
5. 每写完一个函数,在 `examples/` 里加一行最小用法。
|
||||||
|
6. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
||||||
@@ -98,3 +98,19 @@
|
|||||||
想看全貌就偶尔跑一次 `ruff check --select ALL --preview`(只当参考,别直接开进 CI,
|
想看全貌就偶尔跑一次 `ruff check --select ALL --preview`(只当参考,别直接开进 CI,
|
||||||
会淹没在噪声里);纯格式问题交给 `ruff format`,它是 formatter,不受 `select` 影响。
|
会淹没在噪声里);纯格式问题交给 `ruff format`,它是 formatter,不受 `select` 影响。
|
||||||
- 首次:2026-10-01|复发:0
|
- 首次:2026-10-01|复发:0
|
||||||
|
|
||||||
|
### 7. 写"守卫子句"时,忘了不是所有入参都长一样
|
||||||
|
|
||||||
|
- 现象:`winsorize` 开头先算 `numeric_cols = data.select_dtypes(include=['number']).columns`
|
||||||
|
挡"空数值列",但 `winsorize` 同时收 `DataFrame` 和 `Series` ——
|
||||||
|
**`Series` 上没有 `select_dtypes`**,传 Series 直接 `AttributeError`,
|
||||||
|
而且报错就报在守卫那一行(全函数看起来最"安全"的地方)。
|
||||||
|
- 为什么会错:写的时候只想"要防住空数值列",顺手把守卫放在函数最上面,
|
||||||
|
没回头确认**这个函数还收哪些类型**。守卫本身没错,错的是它的**位置**:
|
||||||
|
它对 `DataFrame` 成立、对 `Series` 根本不合法,所以必须排在
|
||||||
|
`isinstance(data, pd.Series)` 的类型分派**之后**。
|
||||||
|
本质是:**先做类型分派,再写只对其中一支成立的逻辑**。
|
||||||
|
- 以后怎么防:写"开头集中校验"之前,先把入参的**全部类型**列一遍,
|
||||||
|
逐条问"这句话对每种类型都成立吗";不成立的往后挪,
|
||||||
|
挪进 `if isinstance(...)` 里面,让它只有对应的那一支能走到。
|
||||||
|
- 首次:2026-10-02|复发:0
|
||||||
@@ -54,6 +54,9 @@ def clean_missing(data: Any, axis: int = 0, threshold: float | None = None) -> A
|
|||||||
if not isinstance(data,pd.DataFrame):
|
if not isinstance(data,pd.DataFrame):
|
||||||
raise TypeError(f"data必须是pandas DataFrame,你传入了{type(data)}")
|
raise TypeError(f"data必须是pandas DataFrame,你传入了{type(data)}")
|
||||||
|
|
||||||
|
if threshold is not None and not 0 <= threshold <= 1:
|
||||||
|
raise ValueError(f"threshold必须在0和1之间,你传入了{threshold}")
|
||||||
|
|
||||||
if threshold is None:
|
if threshold is None:
|
||||||
return data.dropna(axis=target_axis,how='any')
|
return data.dropna(axis=target_axis,how='any')
|
||||||
|
|
||||||
@@ -150,6 +153,9 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg
|
|||||||
>>> bool(mask[-1])
|
>>> bool(mask[-1])
|
||||||
True
|
True
|
||||||
"""
|
"""
|
||||||
|
is_series = isinstance(data, pd.Series)
|
||||||
|
is_ld_array = isinstance(data, (list, np.ndarray)) and np.array(data).ndim == 1
|
||||||
|
|
||||||
if isinstance(data,pd.Series):
|
if isinstance(data,pd.Series):
|
||||||
data = data.to_frame()
|
data = data.to_frame()
|
||||||
elif not isinstance(data,pd.DataFrame):
|
elif not isinstance(data,pd.DataFrame):
|
||||||
@@ -162,30 +168,21 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"不支持的方法:{method},仅支持'iqr'或'zscore'")
|
raise ValueError(f"不支持的方法:{method},仅支持'iqr'或'zscore'")
|
||||||
|
|
||||||
if threshold is not None:
|
|
||||||
if not 0 <= threshold <= 1:
|
|
||||||
raise ValueError(f"threshold必须在0和1之间,你传入了{threshold}")
|
|
||||||
|
|
||||||
is_series = isinstance(data, pd.Series)
|
|
||||||
is_ld_array = isinstance(data, (list, np.ndarray)) and np.array(data).ndim == 1
|
|
||||||
|
|
||||||
df = pd.DataFrame(data)
|
|
||||||
|
|
||||||
if method == 'iqr':
|
if method == 'iqr':
|
||||||
Q1 = df.quantile(0.25)
|
Q1 = data.quantile(0.25)
|
||||||
Q3 = df.quantile(0.75)
|
Q3 = data.quantile(0.75)
|
||||||
IQR = Q3 - Q1
|
IQR = Q3 - Q1
|
||||||
lower_bound = Q1 - k * IQR
|
lower_bound = Q1 - k * IQR
|
||||||
upper_bound = Q3 + k * IQR
|
upper_bound = Q3 + k * IQR
|
||||||
|
|
||||||
mask = (df < lower_bound) | (df > upper_bound)
|
mask = (data < lower_bound) | (data > upper_bound)
|
||||||
|
|
||||||
elif method == 'zscore':
|
elif method == 'zscore':
|
||||||
mean = df.mean()
|
mean = data.mean()
|
||||||
std = df.std()
|
std = data.std()
|
||||||
|
|
||||||
std = std.replace(0, np.nan)
|
std = std.replace(0, np.nan)
|
||||||
z_scores = (df - mean) / std
|
z_scores = (data - mean) / std
|
||||||
mask = z_scores.abs() > threshold
|
mask = z_scores.abs() > threshold
|
||||||
|
|
||||||
if is_series:
|
if is_series:
|
||||||
@@ -227,22 +224,24 @@ def winsorize(data: Any, lower: float = 0.01, upper: float = 0.99) -> Any:
|
|||||||
raise ValueError(f"分位数必须满足0 < lower < upper < 1,你传入了lower={lower},upper={upper}")
|
raise ValueError(f"分位数必须满足0 < lower < upper < 1,你传入了lower={lower},upper={upper}")
|
||||||
|
|
||||||
if isinstance(data,pd.Series):
|
if isinstance(data,pd.Series):
|
||||||
|
if not pd.api.types.is_numeric_dtype(data):
|
||||||
|
return data.copy()
|
||||||
lower_val = data.quantile(lower)
|
lower_val = data.quantile(lower)
|
||||||
upper_val = data.quantile(upper)
|
upper_val = data.quantile(upper)
|
||||||
return data.clip(lower=float(lower_val),upper=float(upper_val))
|
return data.clip(lower=float(lower_val),upper=float(upper_val))
|
||||||
|
|
||||||
lower_bounds = data.quantile(lower)
|
|
||||||
upper_bounds = data.quantile(upper)
|
|
||||||
|
|
||||||
numeric_cols = data.select_dtypes(include=['number']).columns
|
numeric_cols = data.select_dtypes(include=['number']).columns
|
||||||
result = data.copy()
|
|
||||||
|
|
||||||
if len(numeric_cols) == 0:
|
if len(numeric_cols) == 0:
|
||||||
return data.copy()
|
return data.copy()
|
||||||
|
|
||||||
|
lower_bounds = data[numeric_cols].quantile(lower)
|
||||||
|
upper_bounds = data[numeric_cols].quantile(upper)
|
||||||
|
|
||||||
|
result = data.copy()
|
||||||
|
|
||||||
result[numeric_cols] = result[numeric_cols].clip(
|
result[numeric_cols] = result[numeric_cols].clip(
|
||||||
lower=lower_bounds[numeric_cols],
|
lower=lower_bounds,
|
||||||
upper=upper_bounds[numeric_cols],
|
upper=upper_bounds,
|
||||||
axis=1
|
axis=1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -231,20 +231,24 @@ def test_winsorize_rejects_non_frame_with_typeerror():
|
|||||||
|
|
||||||
|
|
||||||
def test_detect_outliers_rejects_unknown_method():
|
def test_detect_outliers_rejects_unknown_method():
|
||||||
"""方法名校验在取随机取值之前,所以这条现在就是绿的。"""
|
"""方法名校验在取默认取值之前,所以这条现在就是绿的。"""
|
||||||
from mcm_toolkit.data import detect_outliers
|
from mcm_toolkit.data import detect_outliers
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="bogus"):
|
with pytest.raises(ValueError, match="bogus"):
|
||||||
detect_outliers([1.0, 2.0], method="bogus") # type: ignore[arg-type]
|
detect_outliers([1.0, 2.0], method="bogus") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------- 待修:契约已写进 docstring,实现还没跟上(xfail)
|
# ------------------- 契约先写进 docstring,实现逐条跟上(剩余 2 条 xfail)
|
||||||
#
|
#
|
||||||
# 这些用例写的是**文档里已经承诺的行为**。现在跑是红的,用 xfail 让它们照样出现在
|
# 这些用例写的是**文档里已经承诺的行为**。实现没跟上时用 xfail,让它们照样出现在
|
||||||
# 报告里(显示成 x);实现修好之后会自动变成 XPASS —— 看到 XPASS 就把 mark 摘掉。
|
# 报告里(显示成 x);修好之后会自动变成 XPASS —— 看到 XPASS 就把 mark 摘掉。
|
||||||
|
#
|
||||||
|
# 已摘掉:threshold 范围校验、IQR 的 UnboundLocalError、(n,1) 形状、zscore 默认阈值、
|
||||||
|
# winsorize 的文本列。剩下 2 条不是漏改,是**设计还没定**:
|
||||||
|
# - detect_outliers 遇到文本列返回什么(全 False?还是只对数值列算掩码?)
|
||||||
|
# - impute_missing 的 mode 分支该不该就地改调用方的表(其余分支都返回新表)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reason="threshold 只在 zscore 分支赋值,iqr 走进去 UnboundLocalError", strict=False)
|
|
||||||
def test_detect_outliers_finds_planted_points():
|
def test_detect_outliers_finds_planted_points():
|
||||||
"""人工埋 3 个离群点,IQR 法应正好命中 3 个。"""
|
"""人工埋 3 个离群点,IQR 法应正好命中 3 个。"""
|
||||||
from mcm_toolkit.data import detect_outliers
|
from mcm_toolkit.data import detect_outliers
|
||||||
@@ -255,7 +259,6 @@ def test_detect_outliers_finds_planted_points():
|
|||||||
assert bool(mask[-3:].all())
|
assert bool(mask[-3:].all())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reason="返回 (n,1),契约是「与原数据同形」", strict=False)
|
|
||||||
def test_detect_outliers_returns_mask_not_filtered_data():
|
def test_detect_outliers_returns_mask_not_filtered_data():
|
||||||
"""约定:只标不删——返回与原数据同形的布尔掩码。"""
|
"""约定:只标不删——返回与原数据同形的布尔掩码。"""
|
||||||
from mcm_toolkit.data import detect_outliers
|
from mcm_toolkit.data import detect_outliers
|
||||||
@@ -266,7 +269,6 @@ def test_detect_outliers_returns_mask_not_filtered_data():
|
|||||||
assert mask.dtype == bool
|
assert mask.dtype == bool
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reason="zscore 的 threshold 被按 [0,1] 校验,默认 3.0 直接被拒", strict=False)
|
|
||||||
def test_detect_outliers_zscore_flags_planted_point():
|
def test_detect_outliers_zscore_flags_planted_point():
|
||||||
from mcm_toolkit.data import detect_outliers
|
from mcm_toolkit.data import detect_outliers
|
||||||
|
|
||||||
@@ -285,8 +287,8 @@ def test_detect_outliers_survives_text_columns():
|
|||||||
assert int(np.asarray(detect_outliers(df)).sum()) == 1
|
assert int(np.asarray(detect_outliers(df)).sum()) == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reason="quantile 在全表上算,遇到文本列直接崩", strict=False)
|
|
||||||
def test_winsorize_survives_text_columns():
|
def test_winsorize_survives_text_columns():
|
||||||
|
"""文本列必须原样留着:分位数只在数值列上算。"""
|
||||||
from mcm_toolkit.data import winsorize
|
from mcm_toolkit.data import winsorize
|
||||||
|
|
||||||
df = pd.DataFrame({"city": ["a", "b", "c", "d"], "v": [1.0, 2.0, 3.0, 1000.0]})
|
df = pd.DataFrame({"city": ["a", "b", "c", "d"], "v": [1.0, 2.0, 3.0, 1000.0]})
|
||||||
@@ -295,7 +297,6 @@ def test_winsorize_survives_text_columns():
|
|||||||
assert float(out["v"].iloc[-1]) < 1000.0
|
assert float(out["v"].iloc[-1]) < 1000.0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reason="threshold 没做范围校验,传 1.5 会变成「什么都不删」", strict=False)
|
|
||||||
def test_clean_missing_rejects_out_of_range_threshold():
|
def test_clean_missing_rejects_out_of_range_threshold():
|
||||||
from mcm_toolkit.data import clean_missing
|
from mcm_toolkit.data import clean_missing
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user