feat(data): detect_outliers 收尾(文本列恒 False)+ 补测试与文档

实现(用户本人写 + Codex 轻度收尾):
- 统计量(quantile / mean / std)与掩码写入都收窄到数值列,含文本列的赛题表不再崩
- 非数值列在掩码里恒 False(用户定的语义),docstring Notes 补上这条
- 删掉死代码 data=data.copy():下面的 to_frame()/pd.DataFrame() 本就返回新对象,
  函数全程只读 data,白复制一份;且它排在类型分派之前,会把错误入参的报错
  从 ValueError 变成 AttributeError
- 删掉那行死代码后 pyright 反而暴露 1 个真错:df[Index] 被类型存根当成 Series,
  .std() 于是被判成标量、std.replace(...) 非法。在取列子表处 cast 成 DataFrame
  按运行时真值收窄(详见 docs/04-environment.md 坑 8)

测试:
- test_detect_outliers_survives_text_columns 摘掉 xfail 并加强断言(文本列必须恒 False)
- 新增 test_detect_outliers_all_text_frame_returns_all_false,钉住"整表无数值列"边界
- 58 passed / 2 xfailed  ->  60 passed / 1 xfailed;ruff 0;pyright 0
- 仅剩 1 条 xfail:impute_missing 的 mode 分支就地改调用方的表(要选语义,留给用户)

文档:
- STATUS.md:L0 5/12、验证结果表、下一步重写、新增决策 32(非数值列恒 False)
- docs/04-environment.md:新增坑 8(pandas 存根让 df[Index] 退化成 Series,及三种无效修法)
- docs/dev/personal-pitfalls.md:只追加第 8 条(限制掩码 ≠ 限制统计量)
- CHANGELOG.md:本轮变更
This commit is contained in:
guzhujushi committed 2026-10-02 15:41:08 +08:00
1 parent 319b16b17c
commit b6dc4bc5a6
6 files changed
+144 -43

No files matched your search

+18 -6
View File
@@ -83,15 +83,27 @@
58 passed / 2 xfailed**。 58 passed / 2 xfailed**。
- `CHANGELOG.md`:[Unreleased] 里有一句被重复追加撑坏 - `CHANGELOG.md`:[Unreleased] 里有一句被重复追加撑坏
("…删掉它即全绿。:分析器会去 PATH 上找 python,"),已修。 ("…删掉它即全绿。:分析器会去 PATH 上找 python,"),已修。
- `data.detect_outliers` 收尾(2026-10-02,**由用户本人实现**):统计量(`quantile` /
`mean` / `std`)与掩码写入**都**收窄到数值列,含文本列的赛题表不再崩;
非数值列在掩码里恒 `False`(用户本人定的语义,见决策 32)。docstring `Notes` 补上这条语义。
- `data.detect_outliers` 里 `data=data.copy()` 是死代码:下面的 `to_frame()` / `pd.DataFrame()`
本来就返回新对象,函数全程也没原地改过 `data`,白白复制一份数据;而且它排在类型分派
**之前**,会把错误入参的报错从 `ValueError` 变成 `AttributeError`。已删。
- `data.detect_outliers`:删掉那行死代码后 pyright 反而报了 1 个真错 ——
`df[Index]` 被类型存根当成 `Series`,`.std()` 于是被判成标量、`std.replace(...)` 非法。
在取列子表处用 `cast(pd.DataFrame, ...)` 按运行时真值收窄。详见 `docs/04-environment.md` 坑 8。
- `tests/test_data.py`:`test_detect_outliers_survives_text_columns` 摘掉 xfail 并加强断言
(文本列必须恒 `False`);新增 `test_detect_outliers_all_text_frame_returns_all_false`
钉住"整表没有数值列"这条边界。测试 **58 passed / 2 xfailed → 60 passed / 1 xfailed**。
- `docs/dev/personal-pitfalls.md`:追加第 8 条(限制掩码 ≠ 限制统计量)。
### Notes ### Notes
- `detect_outliers` 原有三个 bug,现已修掉两个(threshold 范围校验挪进 `clean_missing`、 - `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进
`is_series` / `is_ld_array` 提到类型转换**之前**),**只剩"文本列"这一处**: `clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、
`data.quantile()` 仍在全表上算,含文本列的赛题表一带就崩。 非数值列恒 `False`(决策 32)。最后一条 `xfail` 已按 XPASS 信号摘掉。
这一处要**先定设计**(非数值列返回全 `False`,还是只对数值列给掩码), **仍缺 S 档函数卡片**(`docs/algorithms/`)与 `examples/` 最小用法 —— 见 `STATUS.md`「下一步」第 1/4 条。
所以不在"轻度直接修"的范围内,留给用户本人写 —— 见 `STATUS.md`「下一步」第 1 条。
- `impute_missing` 的 mode 分支会**就地修改**调用方的 DataFrame,其余四个分支都返回新表。 - `impute_missing` 的 mode 分支会**就地修改**调用方的 DataFrame,其余四个分支都返回新表。
同样属于"要选语义",留给用户本人写 —— 见 `STATUS.md`「下一步」第 3 条。 同样属于"要选语义",留给用户本人写 —— 见 `STATUS.md`「下一步」第 2 条。
- 已知缺口: - 已知缺口:
- 除 `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 顺序填实现。
+32 -23
View File
@@ -10,7 +10,7 @@
**第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**, **第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**,
用户本人写 `src/`、Codex 负责审查 + `tests/`(决策 25)。 用户本人写 `src/`、Codex 负责审查 + `tests/`(决策 25)。
当前 4/12:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`。 当前 5/12:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、`detect_outliers`。
## 任务清单 ## 任务清单
@@ -95,15 +95,23 @@
- `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` 只剩"文本列"1 处,且要先定设计(见「下一步」第 1 条) - [x] `data/clean.py` 的 `detect_outliers`(L0 第 5 项,S 档,**实现由用户本人写**)
- [ ] 剩余 8 项:`detect_outliers`(收尾)+ `normalize` `standardize` `describe` - 语义由用户本人定:**非数值列不参与统计,掩码里恒 `False`**(见决策 32)
`correlation_matrix` `bootstrap_ci` `monte_carlo` `save_figure` - 三轮修掉的 4 个 bug:`threshold` 范围校验放错函数、IQR 上界符号、
`is_series` / `is_ld_array` 判在类型转换之后(永远为 False)、文本列上算分位数直接崩
- 最后这轮的关键教训:**限制掩码 ≠ 限制统计量** —— 只把 `mask[numeric_cols] = ...`
收窄是不够的,`quantile()` / `mean()` / `std()` 也要一起收窄(见坑 8 / 个人易错点 8)
- 返回形状与输入同形;Series 进 Series 出(列名保留)、一维数组进一维布尔数组出
- 测试:`tests/test_data.py` 该函数共 11 条,含「全文本表返回全 False」这条边界
- [ ] **待补:** `detect_outliers` 的 S 档函数卡片(`docs/algorithms/`)与 `examples/` 最小用法
- [ ] 剩余 7 项:`normalize` `standardize` `describe` `correlation_matrix`
`bootstrap_ci` `monte_carlo` `save_figure`
### 验证结果 ### 验证结果
| 检查 | venv | conda `mcm-toolkit` | | 检查 | venv | conda `mcm-toolkit` |
|---|---|---| |---|---|---|
| `pytest` | 见下(单列) | **58 passed, 42 skipped, 2 xfailed** | | `pytest` | 见下(单列) | **60 passed, 42 skipped, 1 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 | **All checks passed**(0 error) | | `ruff check` | All checks passed | **All checks passed**(0 error) |
| `scripts/doctor.ps1` | 跑通 | — | | `scripts/doctor.ps1` | 跑通 | — |
@@ -113,10 +121,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(与两套环境一致)
- 58 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 27 条 `data` 行为测试 - 60 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 29 条 `data` 行为测试
- 2 条 `xfail` 是**已知待修**的契约:`detect_outliers` 遇文本列、`impute_missing` 的 mode 就地改。 - 只剩 1 条 `xfail`:`impute_missing` 的 mode 分支就地改调用方的表。
两者都**不是漏改,是设计没定**;修好后会自动变 XPASS,那时把 `xfail` 摘掉即可(决策 30) 这条**不是漏改,是要选语义**;修好后会自动变 XPASS,那时把 `xfail` 摘掉即可(决策 30)
- 上一轮 5 条 `xfail` 正是靠这个 XPASS 信号确认修好并摘掉的 - 累计 6 条 `xfail` 正是靠这个 XPASS 信号确认修好并摘掉的(本轮摘掉 1 条 + 撤销 1 条过严的判定)
- 42 条 skip 是等实现的算法(`normalize` / `standardize` / `train_test_split` 等) - 42 条 skip 是等实现的算法(`normalize` / `standardize` / `train_test_split` 等)
- 三个 `.ps1` 通过 PowerShell AST 语法解析 - 三个 `.ps1` 通过 PowerShell AST 语法解析
@@ -127,8 +135,8 @@
只差 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 完成 **4/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进; - [ ] L0 完成 **5/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
详情见上面 E 节 `detect_outliers`),其余按 `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`。
@@ -172,22 +180,23 @@
| 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 |
| 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 只把选项和影响列清楚 |
| 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`**(`src/mcm_toolkit/data/clean.py`,只剩 1 处,且**要先定设计**): 1. **写 `detect_outliers` 的 S 档函数卡片**(`docs/algorithms/` 下,照 `docs/dev/function-card-template.md`)。
非数值列怎么办 —— 一律返回 `False`,还是只对数值列算掩码? roadmap 要求 S 档先写卡片再写码 —— `detect_outliers` 是反着来的,于是 bug 全翻在
现在 iqr / zscore 两条分支都直接在**全表**上 `quantile()` / `mean()`,赛题表一带文本列就崩。 "该想清楚"的地方:上界符号、布尔优先级、维度。卡片至少要把这几条钉住:
定完把它换成 `data[numeric_cols].quantile(...)`(`winsorize` 已按"只对数值列算"改好,可直接照抄), 两种方法的数学定义与失效场景、非数值列为什么恒 `False`、`k` / `threshold` 默认值从哪来、
并把 `tests/test_data.py::test_detect_outliers_survives_text_columns` 的 xfail 摘掉。 以及"只标不删"这个设计选择。
2. **写 `detect_outliers` 的 S 档函数卡片**(`docs/algorithms/` 下,照 `docs/dev/function-card-template.md`)。 2. 顺手把 `impute_missing` 的 mode 分支改成**返回新表**
roadmap 要求 S 档先写卡片再写码 —— 这次是反着来的,所以三个 bug 都在"该想清楚"的地方翻车:
上界符号、布尔优先级、维度。卡片本身就是防这个的。
3. 顺手把 `impute_missing` 的 mode 分支改成**返回新表**
(现在就地改调用方的 DataFrame,和另外四个分支不一致;`data = data.copy()` 一行的事)。 (现在就地改调用方的 DataFrame,和另外四个分支不一致;`data = data.copy()` 一行的事)。
4. **下一个文件**:`data/transform.py` 的 `normalize` / `standardize` 注意它和 `detect_outliers` 恰好相反:那个函数全程只读,加 `copy()` 是纯浪费 ——
同一个 `copy()`,一处必须要、一处必须不要。
3. **下一个文件**:`data/transform.py` 的 `normalize` / `standardize`
(`tests/test_data.py` 里两条 skip 已就位;写之前先定 `method` 的取值集合与 `axis` 语义)。 (`tests/test_data.py` 里两条 skip 已就位;写之前先定 `method` 的取值集合与 `axis` 语义)。
5. 每写完一个函数,在 `examples/` 里加一行最小用法。 4. 补齐 `examples/`:`Result`、`load_table`、`clean_missing`、`detect_outliers` 都还差一行最小用法
6. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。 (roadmap §3.2 第 4 步)。
5. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
+45
View File
@@ -228,6 +228,51 @@ pythonVersion = "3.13" # 不写就跟着 PATH 上那个 3.14 走
**一句诊断口诀**:编辑器说"解析不到包"时,先跑 `pyright --verbose` 看它的 **一句诊断口诀**:编辑器说"解析不到包"时,先跑 `pyright --verbose` 看它的
`Search paths`,而不是先去 `pip install`。 `Search paths`,而不是先去 `pip install`。
**坑 8 —— `df[Index]` 被 pyright 当成 `Series`,`.std()` 的结果于是被判成标量。**
现象:`data/clean.py` 里
```python
numeric = data[numeric_cols] # numeric_cols = df.select_dtypes(...).columns
std = numeric.std()
std = std.replace(0, np.nan) # pyright: 无法访问类"float"的属性"replace"
```
运行时 `numeric` 明明是 DataFrame、`std` 明明是按列的 Series,pyright 却报这个错。
原因:pandas 自带的类型注解里,`DataFrame.__getitem__` 只声明了「`Hashable` → `Series`」
这一支。拿一个 `Index` 去索引时它挑中这个重载,`numeric` 就被推断成 `Series | DataFrame`;
而 `Series.std()` 返回的是**标量**,两支一取并集就把 `float` 带了进来 —— `float` 当然没有
`.replace`。用 `reveal_type` 一问就现原形:
```text
“numeric”的类型为“Series | Unknown | Self@DataFrame”
“numeric.std(axis=0)”的类型为“Series | float | Unknown”
```
几个**没用**的修法(都实测过):
- `numeric.std(axis=0)` —— 接收方本身还是并集,重载照样解析成并集;
- `data[list(numeric_cols)]` —— 不改变重载选择;
- 把参数改由一个类型明确的 `frame` 局部变量承接 —— `frame` 确实干净了,
但 `frame[Index]` 还是同一个重载问题。
有效的修法是**只在取列子表那一句上按运行时的真值收窄**:
```python
from typing import cast
numeric = cast(pd.DataFrame, data[numeric_cols])
```
`cast` 零运行时开销,而且能把**下面所有行**留在类型检查范围内 —— 比在 `.replace` 那一行
挂 `# pyright: ignore` 好:那样等于连带放弃后面每一行的检查。
**一句诊断口诀**:pyright 说某行"属性不存在"、但运行时确实能跑,先 `reveal_type(...)`
把真实推断打出来,看并集里混进了什么,别急着加 `# type: ignore`。
---
--- ---
## 五、两套环境的实际版本(已对齐) ## 五、两套环境的实际版本(已对齐)
+14
View File
@@ -114,3 +114,17 @@
逐条问"这句话对每种类型都成立吗";不成立的往后挪, 逐条问"这句话对每种类型都成立吗";不成立的往后挪,
挪进 `if isinstance(...)` 里面,让它只有对应的那一支能走到。 挪进 `if isinstance(...)` 里面,让它只有对应的那一支能走到。
- 首次:2026-10-02|复发:0 - 首次:2026-10-02|复发:0
### 8. 改了一处,忘了同源的另一处
- 现象:给 `detect_outliers` 加"文本列不参与"的修复时,只把**掩码写入**收窄到数值列
(`mask[numeric_cols] = ...`),却让 `data.quantile()` / `data.mean()` / `data.std()`
继续在全表上算。测试照样红 —— 文本列在**算统计量**那一步就崩了,跟掩码怎么写没关系。
- 为什么会错:眼睛盯着"结果往哪放"(掩码),没回头问"这些数是从哪儿来的"。
同一份数据在函数里要过两道加工:**先出统计量(分位数 / 均值 / 标准差),再拿去比较**。
只把后一道收窄、前一道还是全表,等于没修。
修完测试报告里那条仍是 XFAIL,就是这个信号 —— 报告没变绿,说明根因没抓着。
- 以后怎么防:修"某处不该参与计算"这类问题时,顺着数据从入到出**走一遍**,
把该收窄的地方一次列全,别改完第一处就收手;
并且以测试信号(XFAIL → XPASS)为准,别以"我改的那一行看起来对了"为准。
- 首次:2026-10-02|复发:0
+18 -8
View File
@@ -8,7 +8,7 @@
from __future__ import annotations from __future__ import annotations
import math import math
from typing import Any, Literal from typing import Any, Literal, cast
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@@ -147,6 +147,9 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg
**只标不删**是刻意的:删点是有代价的判断,必须由人来做, **只标不删**是刻意的:删点是有代价的判断,必须由人来做,
并且要在论文里写清楚删了几个、依据是什么。 并且要在论文里写清楚删了几个、依据是什么。
非数值列**不参与统计**,掩码里恒为 `False`:均值和分位数对文本没有意义,
但"这一列没有离群点"比"整张表算不出来"有用得多。
Examples Examples
-------- --------
>>> mask = detect_outliers([1, 2, 3, 4, 100], method="iqr") >>> mask = detect_outliers([1, 2, 3, 4, 100], method="iqr")
@@ -168,22 +171,29 @@ 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'")
numeric_cols = data.select_dtypes(include=['number']).columns
mask = pd.DataFrame(False,index=data.index,columns=data.columns)
# 取列子表得到的一定是 DataFrame,但 pandas 的类型存根只声明了「Hashable -> Series」,
# pyright 于是把 numeric 当成 Series,再把 `.std()` 的返回当成标量、判 `.replace` 非法。
# 这里按运行时的真值显式收窄,让下面几行继续参与类型检查。
numeric = cast(pd.DataFrame, data[numeric_cols])
if method == 'iqr': if method == 'iqr':
Q1 = data.quantile(0.25) Q1 = numeric.quantile(0.25)
Q3 = data.quantile(0.75) Q3 = numeric.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 = (data < lower_bound) | (data > upper_bound) mask[numeric_cols] = (numeric < lower_bound) | (numeric > upper_bound)
elif method == 'zscore': elif method == 'zscore':
mean = data.mean() mean = numeric.mean()
std = data.std() std = numeric.std()
std = std.replace(0, np.nan) std = std.replace(0, np.nan)
z_scores = (data - mean) / std z_scores = (numeric - mean) / std
mask = z_scores.abs() > threshold mask[numeric_cols] = z_scores.abs() > threshold
if is_series: if is_series:
return mask.iloc[:,0] return mask.iloc[:,0]
+17 -6
View File
@@ -238,14 +238,13 @@ def test_detect_outliers_rejects_unknown_method():
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,实现逐条跟上(剩余 2 条 xfail) # ------------------- 契约先写进 docstring,实现逐条跟上(剩余 1 条 xfail)
# #
# 这些用例写的是**文档里已经承诺的行为**。实现没跟上时用 xfail,让它们照样出现在 # 这些用例写的是**文档里已经承诺的行为**。实现没跟上时用 xfail,让它们照样出现在
# 报告里(显示成 x);修好之后会自动变成 XPASS —— 看到 XPASS 就把 mark 摘掉。 # 报告里(显示成 x);修好之后会自动变成 XPASS —— 看到 XPASS 就把 mark 摘掉。
# #
# 已摘掉:threshold 范围校验、IQR 的 UnboundLocalError、(n,1) 形状、zscore 默认阈值、 # 已摘掉:threshold 范围校验、IQR 的 UnboundLocalError、(n,1) 形状、zscore 默认阈值、
# winsorize 的文本列。剩下 2 条不是漏改,是**设计还没定**: # winsorize 的文本列、detect_outliers 的文本列。剩 1 条不是漏改,是**设计还没定**:
# - detect_outliers 遇到文本列返回什么(全 False?还是只对数值列算掩码?)
# - impute_missing 的 mode 分支该不该就地改调用方的表(其余分支都返回新表) # - impute_missing 的 mode 分支该不该就地改调用方的表(其余分支都返回新表)
@@ -278,13 +277,25 @@ def test_detect_outliers_zscore_flags_planted_point():
assert bool(mask[-1].all()) assert bool(mask[-1].all())
@pytest.mark.xfail(reason="quantile 在全表上算,遇到文本列直接崩", strict=False)
def test_detect_outliers_survives_text_columns(): def test_detect_outliers_survives_text_columns():
"""赛题表几乎都带文本列,分位数只能在数值列上算。""" """赛题表几乎都带文本列:分位数只在数值列上算,文本列恒 False。"""
from mcm_toolkit.data import detect_outliers from mcm_toolkit.data import detect_outliers
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]})
assert int(np.asarray(detect_outliers(df)).sum()) == 1 mask = detect_outliers(df)
assert int(np.asarray(mask).sum()) == 1
assert not bool(np.asarray(mask["city"]).any())
assert bool(np.asarray(mask["v"]).any())
def test_detect_outliers_all_text_frame_returns_all_false():
"""整表没有数值列时不报错,返回全 False 掩码(语义:这一列没有离群点)。"""
from mcm_toolkit.data import detect_outliers
df = pd.DataFrame({"a": ["x", "y"], "b": ["z", "w"]})
mask = detect_outliers(df)
assert mask.shape == df.shape
assert not bool(np.asarray(mask).any())
def test_winsorize_survives_text_columns(): def test_winsorize_survives_text_columns():