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**。
- `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 顺序填实现。
+32 -23
View File
@@ -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)走完整流程,反向沉淀函数。
+45
View File
@@ -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`。
---
---
## 五、两套环境的实际版本(已对齐)
+14
View File
@@ -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
+18 -8
View File
@@ -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]
+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]
# ------------------- 契约先写进 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():