From b6dc4bc5a6115418ae8b392ac84a3f25501536bc Mon Sep 17 00:00:00 2001 From: guzhujushi Date: Fri, 2 Oct 2026 15:41:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(data):=20detect=5Foutliers=20=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=EF=BC=88=E6=96=87=E6=9C=AC=E5=88=97=E6=81=92=20False?= =?UTF-8?q?=EF=BC=89+=20=E8=A1=A5=E6=B5=8B=E8=AF=95=E4=B8=8E=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现(用户本人写 + 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:本轮变更 --- CHANGELOG.md | 24 +++++++++++---- STATUS.md | 55 ++++++++++++++++++++--------------- docs/04-environment.md | 45 ++++++++++++++++++++++++++++ docs/dev/personal-pitfalls.md | 14 +++++++++ src/mcm_toolkit/data/clean.py | 26 ++++++++++++----- tests/test_data.py | 23 +++++++++++---- 6 files changed, 144 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f40c6..4bfaff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,15 +83,27 @@ 58 passed / 2 xfailed**。 - `CHANGELOG.md`:[Unreleased] 里有一句被重复追加撑坏 ("…删掉它即全绿。:分析器会去 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 -- `detect_outliers` 原有三个 bug,现已修掉两个(threshold 范围校验挪进 `clean_missing`、 - `is_series` / `is_ld_array` 提到类型转换**之前**),**只剩"文本列"这一处**: - `data.quantile()` 仍在全表上算,含文本列的赛题表一带就崩。 - 这一处要**先定设计**(非数值列返回全 `False`,还是只对数值列给掩码), - 所以不在"轻度直接修"的范围内,留给用户本人写 —— 见 `STATUS.md`「下一步」第 1 条。 +- `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进 + `clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、 + 非数值列恒 `False`(决策 32)。最后一条 `xfail` 已按 XPASS 信号摘掉。 + **仍缺 S 档函数卡片**(`docs/algorithms/`)与 `examples/` 最小用法 —— 见 `STATUS.md`「下一步」第 1/4 条。 - `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)` 标记)。 这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。 diff --git a/STATUS.md b/STATUS.md index b2ed28c..cfa7bfa 100644 --- a/STATUS.md +++ b/STATUS.md @@ -10,7 +10,7 @@ **第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**, 用户本人写 `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, 写 `int()` 截断会漏删 - 顺手落地兄弟函数 `impute_missing` / `winsorize`(原属 L1) -- [ ] **待收尾:** `detect_outliers` 只剩"文本列"1 处,且要先定设计(见「下一步」第 1 条) -- [ ] 剩余 8 项:`detect_outliers`(收尾)+ `normalize` `standardize` `describe` - `correlation_matrix` `bootstrap_ci` `monte_carlo` `save_figure` +- [x] `data/clean.py` 的 `detect_outliers`(L0 第 5 项,S 档,**实现由用户本人写**) + - 语义由用户本人定:**非数值列不参与统计,掩码里恒 `False`**(见决策 32) + - 三轮修掉的 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` | |---|---|---| -| `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 项 | | `ruff check` | All checks passed | **All checks passed**(0 error) | | `scripts/doctor.ps1` | 跑通 | — | @@ -113,10 +121,10 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- 58 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 27 条 `data` 行为测试 -- 2 条 `xfail` 是**已知待修**的契约:`detect_outliers` 遇文本列、`impute_missing` 的 mode 就地改。 - 两者都**不是漏改,是设计没定**;修好后会自动变 XPASS,那时把 `xfail` 摘掉即可(决策 30) -- 上一轮 5 条 `xfail` 正是靠这个 XPASS 信号确认修好并摘掉的 +- 60 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 29 条 `data` 行为测试 +- 只剩 1 条 `xfail`:`impute_missing` 的 mode 分支就地改调用方的表。 + 这条**不是漏改,是要选语义**;修好后会自动变 XPASS,那时把 `xfail` 摘掉即可(决策 30) +- 累计 6 条 `xfail` 正是靠这个 XPASS 信号确认修好并摘掉的(本轮摘掉 1 条 + 撤销 1 条过严的判定) - 42 条 skip 是等实现的算法(`normalize` / `standardize` / `train_test_split` 等) - 三个 `.ps1` 通过 PowerShell AST 语法解析 @@ -127,8 +135,8 @@ 只差 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 完成 **4/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进; - 详情见上面 E 节 +- [ ] L0 完成 **5/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、 + `detect_outliers`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进;详情见上面 E 节 - [ ] 本机 `exec_command` 在沙箱模式下**完全不可用** (`helper_unknown_error: setup refresh had errors`),只有提权 (`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 不失败,不会卡住提交 | | 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 | +| 32 | `detect_outliers` 对非数值列恒返回 `False`(既不跳过也不报错) | **用户本人在两条路里选的**。跳过会让 docstring 里"与原数据同形"变成谎话(形状对不上,没法按位置对回原表);报错则让一张带 `city` 列的真题表直接不可用。`False` 的读法是"这一列没有离群点",对文本列是最不坏的解释,也让整表能一次跑完 | | 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 自动跳过 | ## 下一步 -1. **收尾 `detect_outliers`**(`src/mcm_toolkit/data/clean.py`,只剩 1 处,且**要先定设计**): - 非数值列怎么办 —— 一律返回 `False`,还是只对数值列算掩码? - 现在 iqr / zscore 两条分支都直接在**全表**上 `quantile()` / `mean()`,赛题表一带文本列就崩。 - 定完把它换成 `data[numeric_cols].quantile(...)`(`winsorize` 已按"只对数值列算"改好,可直接照抄), - 并把 `tests/test_data.py::test_detect_outliers_survives_text_columns` 的 xfail 摘掉。 -2. **写 `detect_outliers` 的 S 档函数卡片**(`docs/algorithms/` 下,照 `docs/dev/function-card-template.md`)。 - roadmap 要求 S 档先写卡片再写码 —— 这次是反着来的,所以三个 bug 都在"该想清楚"的地方翻车: - 上界符号、布尔优先级、维度。卡片本身就是防这个的。 -3. 顺手把 `impute_missing` 的 mode 分支改成**返回新表** +1. **写 `detect_outliers` 的 S 档函数卡片**(`docs/algorithms/` 下,照 `docs/dev/function-card-template.md`)。 + roadmap 要求 S 档先写卡片再写码 —— `detect_outliers` 是反着来的,于是 bug 全翻在 + "该想清楚"的地方:上界符号、布尔优先级、维度。卡片至少要把这几条钉住: + 两种方法的数学定义与失效场景、非数值列为什么恒 `False`、`k` / `threshold` 默认值从哪来、 + 以及"只标不删"这个设计选择。 +2. 顺手把 `impute_missing` 的 mode 分支改成**返回新表** (现在就地改调用方的 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` 语义)。 -5. 每写完一个函数,在 `examples/` 里加一行最小用法。 -6. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。 +4. 补齐 `examples/`:`Result`、`load_table`、`clean_missing`、`detect_outliers` 都还差一行最小用法 + (roadmap §3.2 第 4 步)。 +5. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。 diff --git a/docs/04-environment.md b/docs/04-environment.md index d32e6b6..89bd58d 100644 --- a/docs/04-environment.md +++ b/docs/04-environment.md @@ -228,6 +228,51 @@ pythonVersion = "3.13" # 不写就跟着 PATH 上那个 3.14 走 **一句诊断口诀**:编辑器说"解析不到包"时,先跑 `pyright --verbose` 看它的 `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`。 + +--- + --- ## 五、两套环境的实际版本(已对齐) diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index 5e15893..8528e0c 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -114,3 +114,17 @@ 逐条问"这句话对每种类型都成立吗";不成立的往后挪, 挪进 `if isinstance(...)` 里面,让它只有对应的那一支能走到。 - 首次:2026-10-02|复发:0 + +### 8. 改了一处,忘了同源的另一处 + +- 现象:给 `detect_outliers` 加"文本列不参与"的修复时,只把**掩码写入**收窄到数值列 + (`mask[numeric_cols] = ...`),却让 `data.quantile()` / `data.mean()` / `data.std()` + 继续在全表上算。测试照样红 —— 文本列在**算统计量**那一步就崩了,跟掩码怎么写没关系。 +- 为什么会错:眼睛盯着"结果往哪放"(掩码),没回头问"这些数是从哪儿来的"。 + 同一份数据在函数里要过两道加工:**先出统计量(分位数 / 均值 / 标准差),再拿去比较**。 + 只把后一道收窄、前一道还是全表,等于没修。 + 修完测试报告里那条仍是 XFAIL,就是这个信号 —— 报告没变绿,说明根因没抓着。 +- 以后怎么防:修"某处不该参与计算"这类问题时,顺着数据从入到出**走一遍**, + 把该收窄的地方一次列全,别改完第一处就收手; + 并且以测试信号(XFAIL → XPASS)为准,别以"我改的那一行看起来对了"为准。 +- 首次:2026-10-02|复发:0 diff --git a/src/mcm_toolkit/data/clean.py b/src/mcm_toolkit/data/clean.py index 7364926..3fe45f2 100644 --- a/src/mcm_toolkit/data/clean.py +++ b/src/mcm_toolkit/data/clean.py @@ -8,7 +8,7 @@ from __future__ import annotations import math -from typing import Any, Literal +from typing import Any, Literal, cast import numpy as np import pandas as pd @@ -147,6 +147,9 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg **只标不删**是刻意的:删点是有代价的判断,必须由人来做, 并且要在论文里写清楚删了几个、依据是什么。 + 非数值列**不参与统计**,掩码里恒为 `False`:均值和分位数对文本没有意义, + 但"这一列没有离群点"比"整张表算不出来"有用得多。 + Examples -------- >>> 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: 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': - Q1 = data.quantile(0.25) - Q3 = data.quantile(0.75) + Q1 = numeric.quantile(0.25) + Q3 = numeric.quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - 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': - mean = data.mean() - std = data.std() + mean = numeric.mean() + std = numeric.std() std = std.replace(0, np.nan) - z_scores = (data - mean) / std - mask = z_scores.abs() > threshold + z_scores = (numeric - mean) / std + mask[numeric_cols] = z_scores.abs() > threshold if is_series: return mask.iloc[:,0] diff --git a/tests/test_data.py b/tests/test_data.py index 21dbaeb..86c427e 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -238,14 +238,13 @@ def test_detect_outliers_rejects_unknown_method(): detect_outliers([1.0, 2.0], method="bogus") # type: ignore[arg-type] -# ------------------- 契约先写进 docstring,实现逐条跟上(剩余 2 条 xfail) +# ------------------- 契约先写进 docstring,实现逐条跟上(剩余 1 条 xfail) # # 这些用例写的是**文档里已经承诺的行为**。实现没跟上时用 xfail,让它们照样出现在 # 报告里(显示成 x);修好之后会自动变成 XPASS —— 看到 XPASS 就把 mark 摘掉。 # # 已摘掉:threshold 范围校验、IQR 的 UnboundLocalError、(n,1) 形状、zscore 默认阈值、 -# winsorize 的文本列。剩下 2 条不是漏改,是**设计还没定**: -# - detect_outliers 遇到文本列返回什么(全 False?还是只对数值列算掩码?) +# winsorize 的文本列、detect_outliers 的文本列。剩 1 条不是漏改,是**设计还没定**: # - impute_missing 的 mode 分支该不该就地改调用方的表(其余分支都返回新表) @@ -278,13 +277,25 @@ def test_detect_outliers_zscore_flags_planted_point(): assert bool(mask[-1].all()) -@pytest.mark.xfail(reason="quantile 在全表上算,遇到文本列直接崩", strict=False) def test_detect_outliers_survives_text_columns(): - """赛题表几乎都带文本列,分位数只能在数值列上算。""" + """赛题表几乎都带文本列:分位数只在数值列上算,文本列恒 False。""" from mcm_toolkit.data import detect_outliers 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():