chore: 补 S 档卡片 / examples;clean.py 收尾,xfail 清零
实现收尾(Codex 代写,用户授权): - impute_missing 的 mode 分支改为返回新表(原先就地改调用方的 DataFrame,另外四支都返回新对象) - detect_outliers 补入参类型校验(§4.7:非法类型抛 TypeError,不静默兜底): 接受 DataFrame / Series / list / tuple / ndarray;tuple 同时补进 is_ld_array; dict 不再被接受(以前靠 pd.DataFrame() 碰巧能过,见决策 33) 测试: - 摘掉最后一条 xfail(mode 就地改)并加强断言 - 新增 3 条:非法类型抛 TypeError、元组按一维数组型处理、全 NaN 列与单元素列不抛异常 - 64 passed / 42 skipped / 0 xfailed(xfail 清零,7 条契约全部转正);ruff 0;pyright 0 文档: - 新增 docs/algorithms/detect_outliers.md —— 第一张 S 档函数卡片(roadmap 要求约 15 张) 除模板九节外多记三条判断:为什么不返回 Result、非数值列恒 False 的两条备选路各错在哪、 ddof 口径与 stats.zscore 不一致(前者 1、后者 0,小样本差一成) - 新增 examples/01_clean_and_describe.py —— 六个 data 函数的最小用法,实测跑通(exit 0) - STATUS.md:E 节收尾、验证结果表、下一步重写(transform/standardize 的待定项)、决策 33 - CHANGELOG.md:把 [Unreleased] 里重复 7 次的 Added/Changed 小节合并成 Added/Changed/Notes; 并修掉上轮遗留的缺陷 —— pyright 那条 bullet 的开头被吞掉,半句挂在 ruff 那条后面
This commit is contained in:
1 parent
b6dc4bc5a6
commit
5749e50e83
8 files changed
+344
-51
No files matched your search
+28
-18
@@ -19,6 +19,20 @@
|
|||||||
- `scripts/setup-conda.ps1` / `scripts/setup-venv.ps1` / `scripts/doctor.ps1`
|
- `scripts/setup-conda.ps1` / `scripts/setup-venv.ps1` / `scripts/doctor.ps1`
|
||||||
- `environment.yml`:需联网时的精确复现方案
|
- `environment.yml`:需联网时的精确复现方案
|
||||||
- `pyproject.toml`:核心依赖只 4 个,其余走 extras
|
- `pyproject.toml`:核心依赖只 4 个,其余走 extras
|
||||||
|
- `docs/02-architecture.md` §4.7「类型约定」:注解要写全、numpy 标量的判断写法、
|
||||||
|
非法类型必须抛 `TypeError`。配套在 `docs/03-roadmap.md` 的开发纪律里加了指针。
|
||||||
|
- `docs/dev/personal-pitfalls.md`:个人易错点清单,**只追加、不通读**
|
||||||
|
- `data.clean`:四个函数落地(**由用户本人实现**)——`clean_missing`(L0 第 4 项)、
|
||||||
|
`impute_missing` / `detect_outliers` / `winsorize`(原属 L1,顺手写了)。
|
||||||
|
- `tests/test_data.py`:新增 13 条 clean 行为测试,覆盖 `clean_missing` 的阈值语义与轴向、
|
||||||
|
`impute_missing` 的 mean/ffill/mode 与全 NaN 列、`winsorize` 的分位点与异常类型。
|
||||||
|
- `tests/test_data.py`:另加 7 条 `xfail`,把**已知但未修的契约**钉在测试报告里(见 Notes)。
|
||||||
|
- `docs/algorithms/detect_outliers.md`:**第一张 S 档函数卡片**(roadmap §3.1 要求
|
||||||
|
约 15 张,这是第 1 张)。除模板九节外,额外记了三条容易踩的判断:为什么不返回 `Result`、
|
||||||
|
非数值列恒 `False` 的两条备选路各错在哪、以及 `ddof` 口径与 `stats.zscore` 不一致。
|
||||||
|
- `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` /
|
||||||
|
`impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测跑通(exit 0);
|
||||||
|
末尾留 `TODO(L0)` 等 `stats.describe`。
|
||||||
|
|
||||||
### 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` /
|
||||||
@@ -34,25 +48,12 @@
|
|||||||
- `data.load_table` / `data.save_table`:docstring 补 `Parameters` / `Raises`
|
- `data.load_table` / `data.save_table`:docstring 补 `Parameters` / `Raises`
|
||||||
(`docs/02-architecture.md` §4.7 对每个公开函数的硬要求)。
|
(`docs/02-architecture.md` §4.7 对每个公开函数的硬要求)。
|
||||||
- `data.load_table` 的返回注解 `Any` → `pd.DataFrame`,与 docstring 的承诺对齐。
|
- `data.load_table` 的返回注解 `Any` → `pd.DataFrame`,与 docstring 的承诺对齐。
|
||||||
|
|
||||||
### Added
|
|
||||||
- `docs/02-architecture.md` §4.7「类型约定」:注解要写全、numpy 标量的判断写法、
|
|
||||||
非法类型必须抛 `TypeError`。配套在 `docs/03-roadmap.md` 的开发纪律里加了指针。
|
|
||||||
- `docs/dev/personal-pitfalls.md`:个人易错点清单,**只追加、不通读**
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `data.clean`:四个函数落地(**由用户本人实现**)——`clean_missing`(L0 第 4 项)、
|
|
||||||
`impute_missing` / `detect_outliers` / `winsorize`(原属 L1,顺手写了)。
|
|
||||||
- `tests/test_data.py`:新增 13 条 clean 行为测试,覆盖 `clean_missing` 的阈值语义与轴向、
|
|
||||||
`impute_missing` 的 mean/ffill/mode 与全 NaN 列、`winsorize` 的分位点与异常类型。
|
|
||||||
- `tests/test_data.py`:另加 7 条 `xfail`,把**已知但未修的契约**钉在测试报告里(见 Notes)。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `data.clean`:docstring 按 `docs/02-architecture.md` §4.7 补全 Parameters / Returns / Raises
|
- `data.clean`:docstring 按 `docs/02-architecture.md` §4.7 补全 Parameters / Returns / Raises
|
||||||
(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)。
|
去掉行尾空白(W293)。
|
||||||
|
- `pyright` 报"无法解析导入 numpy / pandas"(17 errors)的原因:分析器会去 PATH 上找 python,
|
||||||
本机 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"` /
|
||||||
@@ -96,14 +97,23 @@
|
|||||||
(文本列必须恒 `False`);新增 `test_detect_outliers_all_text_frame_returns_all_false`
|
(文本列必须恒 `False`);新增 `test_detect_outliers_all_text_frame_returns_all_false`
|
||||||
钉住"整表没有数值列"这条边界。测试 **58 passed / 2 xfailed → 60 passed / 1 xfailed**。
|
钉住"整表没有数值列"这条边界。测试 **58 passed / 2 xfailed → 60 passed / 1 xfailed**。
|
||||||
- `docs/dev/personal-pitfalls.md`:追加第 8 条(限制掩码 ≠ 限制统计量)。
|
- `docs/dev/personal-pitfalls.md`:追加第 8 条(限制掩码 ≠ 限制统计量)。
|
||||||
|
- `data.impute_missing` 的 mode 分支改为**返回新表**(原先 `data[col] = ...` 会就地改调用方的
|
||||||
|
DataFrame,另外四支都返回新对象)。最后一条 `xfail` 随之转正,**测试里 `xfail` 清零**。
|
||||||
|
- `data.detect_outliers` 补**入参类型校验**(§4.7:非法类型抛 `TypeError`,不静默兜底):
|
||||||
|
接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`。`tuple` 同时补进 `is_ld_array`,
|
||||||
|
与 `list` 同等对待;`dict` 不再被接受(以前靠 `pd.DataFrame()` 碰巧能过,见决策 33)。
|
||||||
|
- `tests/test_data.py`:摘掉最后一条 `xfail`(mode 就地改)并加强断言;新增 3 条
|
||||||
|
(非法类型抛 `TypeError`、元组按一维数组型处理、全 NaN 列与单元素列不抛异常)。
|
||||||
|
测试 **60 passed / 1 xfailed → 64 passed / 0 xfailed**。
|
||||||
|
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进
|
- `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进
|
||||||
`clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、
|
`clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、
|
||||||
非数值列恒 `False`(决策 32)。最后一条 `xfail` 已按 XPASS 信号摘掉。
|
非数值列恒 `False`(决策 32)、入参类型校验补上(决策 33)。
|
||||||
**仍缺 S 档函数卡片**(`docs/algorithms/`)与 `examples/` 最小用法 —— 见 `STATUS.md`「下一步」第 1/4 条。
|
S 档卡片与 `examples/` 用法也已落地 —— `detect_outliers` 这条线**结清**。
|
||||||
- `impute_missing` 的 mode 分支会**就地修改**调用方的 DataFrame,其余四个分支都返回新表。
|
- 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `stats` / `viz`,
|
||||||
同样属于"要选语义",留给用户本人写 —— 见 `STATUS.md`「下一步」第 2 条。
|
`Result` 的用法届时并进去。见 `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 顺序填实现。
|
||||||
|
|||||||
@@ -99,11 +99,19 @@
|
|||||||
- 语义由用户本人定:**非数值列不参与统计,掩码里恒 `False`**(见决策 32)
|
- 语义由用户本人定:**非数值列不参与统计,掩码里恒 `False`**(见决策 32)
|
||||||
- 三轮修掉的 4 个 bug:`threshold` 范围校验放错函数、IQR 上界符号、
|
- 三轮修掉的 4 个 bug:`threshold` 范围校验放错函数、IQR 上界符号、
|
||||||
`is_series` / `is_ld_array` 判在类型转换之后(永远为 False)、文本列上算分位数直接崩
|
`is_series` / `is_ld_array` 判在类型转换之后(永远为 False)、文本列上算分位数直接崩
|
||||||
- 最后这轮的关键教训:**限制掩码 ≠ 限制统计量** —— 只把 `mask[numeric_cols] = ...`
|
- 关键教训:**限制掩码 ≠ 限制统计量** —— 只把 `mask[numeric_cols] = ...`
|
||||||
收窄是不够的,`quantile()` / `mean()` / `std()` 也要一起收窄(见坑 8 / 个人易错点 8)
|
收窄是不够的,`quantile()` / `mean()` / `std()` 也要一起收窄(见坑 8 / 个人易错点 8)
|
||||||
- 返回形状与输入同形;Series 进 Series 出(列名保留)、一维数组进一维布尔数组出
|
- 返回形状与输入同形;Series 进 Series 出(列名保留)、一维数组进一维布尔数组出
|
||||||
- 测试:`tests/test_data.py` 该函数共 11 条,含「全文本表返回全 False」这条边界
|
- **S 档函数卡片已写**:`docs/algorithms/detect_outliers.md`
|
||||||
- [ ] **待补:** `detect_outliers` 的 S 档函数卡片(`docs/algorithms/`)与 `examples/` 最小用法
|
(含"为什么不返回 `Result`"、非数值列恒 `False` 的两条备选路、`ddof` 口径不一致等)
|
||||||
|
- 测试:`tests/test_data.py` 该函数共 **9 条**
|
||||||
|
- [x] `data/clean.py` 的两处收尾(**Codex 代写,用户授权**)
|
||||||
|
- `impute_missing` 的 mode 分支改为**返回新表**(原先就地改调用方的表,与另外四支不一致)
|
||||||
|
—— 最后一条 `xfail` 随之转正,**`xfail` 清零**
|
||||||
|
- `detect_outliers` 补**入参类型校验**(§4.7 要求非法类型抛 `TypeError`):
|
||||||
|
接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`,`dict` 不再接受(决策 33)
|
||||||
|
- [x] `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` /
|
||||||
|
`impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测可直接跑通
|
||||||
- [ ] 剩余 7 项:`normalize` `standardize` `describe` `correlation_matrix`
|
- [ ] 剩余 7 项:`normalize` `standardize` `describe` `correlation_matrix`
|
||||||
`bootstrap_ci` `monte_carlo` `save_figure`
|
`bootstrap_ci` `monte_carlo` `save_figure`
|
||||||
|
|
||||||
@@ -111,7 +119,7 @@
|
|||||||
|
|
||||||
| 检查 | venv | conda `mcm-toolkit` |
|
| 检查 | venv | conda `mcm-toolkit` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `pytest` | 见下(单列) | **60 passed, 42 skipped, 1 xfailed** |
|
| `pytest` | 见下(单列) | **64 passed, 42 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` | 跑通 | — |
|
||||||
@@ -121,10 +129,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(与两套环境一致)
|
||||||
- 60 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 29 条 `data` 行为测试
|
- 64 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 33 条 `data` 行为测试
|
||||||
- 只剩 1 条 `xfail`:`impute_missing` 的 mode 分支就地改调用方的表。
|
- **`xfail` 清零**:累计 7 条"契约已写进 docstring、实现没跟上"的用例全部转正,
|
||||||
这条**不是漏改,是要选语义**;修好后会自动变 XPASS,那时把 `xfail` 摘掉即可(决策 30)
|
决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环
|
||||||
- 累计 6 条 `xfail` 正是靠这个 XPASS 信号确认修好并摘掉的(本轮摘掉 1 条 + 撤销 1 条过严的判定)
|
- `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz`
|
||||||
- 42 条 skip 是等实现的算法(`normalize` / `standardize` / `train_test_split` 等)
|
- 42 条 skip 是等实现的算法(`normalize` / `standardize` / `train_test_split` 等)
|
||||||
- 三个 `.ps1` 通过 PowerShell AST 语法解析
|
- 三个 `.ps1` 通过 PowerShell AST 语法解析
|
||||||
|
|
||||||
@@ -180,23 +188,24 @@
|
|||||||
| 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 |
|
||||||
|
| 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 只把选项和影响列清楚 |
|
||||||
| 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` 的 S 档函数卡片**(`docs/algorithms/` 下,照 `docs/dev/function-card-template.md`)。
|
1. **`data/transform.py` 的 `normalize` / `standardize`**(用户本人开始写)
|
||||||
roadmap 要求 S 档先写卡片再写码 —— `detect_outliers` 是反着来的,于是 bug 全翻在
|
- 签名已经定好(`normalize(x, method="minmax", feature_range=(0.0, 1.0))`、
|
||||||
"该想清楚"的地方:上界符号、布尔优先级、维度。卡片至少要把这几条钉住:
|
`standardize(x, ddof=0)`),动手前只需要定两件事:`normalize` 的 `method` 取值集合
|
||||||
两种方法的数学定义与失效场景、非数值列为什么恒 `False`、`k` / `threshold` 默认值从哪来、
|
(默认 `"minmax"`;要不要再放 `"maxabs"` / `"robust"`?YAGNI 的话就只留 `"minmax"`)、
|
||||||
以及"只标不删"这个设计选择。
|
以及 `axis` 语义 —— 赛题里几乎总是**按列**,那就明确默认按列,别留模糊。
|
||||||
2. 顺手把 `impute_missing` 的 mode 分支改成**返回新表**
|
- 注意 `standardize` 的 `ddof=0` 与 `detect_outliers` 里 pandas 默认的 `ddof=1` 不一致,
|
||||||
(现在就地改调用方的 DataFrame,和另外四个分支不一致;`data = data.copy()` 一行的事)。
|
两处都要在 docstring 里写明,否则以后对不上账(见 `docs/algorithms/detect_outliers.md` §5)。
|
||||||
注意它和 `detect_outliers` 恰好相反:那个函数全程只读,加 `copy()` 是纯浪费 ——
|
- `tests/test_data.py` 里两条 skip 已就位(`test_normalize_minmax_range`、
|
||||||
同一个 `copy()`,一处必须要、一处必须不要。
|
`test_standardize_has_zero_mean_unit_std`)。
|
||||||
3. **下一个文件**:`data/transform.py` 的 `normalize` / `standardize`
|
2. `stats.describe` + `data/transform.py` 的 `log_transform` / `one_hot_encode`
|
||||||
(`tests/test_data.py` 里两条 skip 已就位;写之前先定 `method` 的取值集合与 `axis` 语义)。
|
(`examples/01_clean_and_describe.py` 末尾的 `TODO(L0)` 就等 `describe`)。
|
||||||
4. 补齐 `examples/`:`Result`、`load_table`、`clean_missing`、`detect_outliers` 都还差一行最小用法
|
3. 补齐 `examples/00_quickstart.py`:它要等 `stats` / `viz`;`Result` 的用法届时一并写进去。
|
||||||
(roadmap §3.2 第 4 步)。
|
4. 每写完一个函数:docstring(§4.7 全套)+ 一条 pytest + `examples/` 一行用法 + 更新 `STATUS.md`。
|
||||||
5. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
5. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
||||||
@@ -7,7 +7,7 @@ S 档(自己实现的招牌算法)的**函数卡片**放这里,模板见
|
|||||||
|
|
||||||
| 卡片 | 对应函数 | 状态 |
|
| 卡片 | 对应函数 | 状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `detect_outliers.md` | `data.detect_outliers` | ⬜ |
|
| `detect_outliers.md` | `data.detect_outliers` | ✅ 2026-10-02 |
|
||||||
| `bootstrap_ci.md` | `stats.bootstrap_ci` | ⬜ |
|
| `bootstrap_ci.md` | `stats.bootstrap_ci` | ⬜ |
|
||||||
| `monte_carlo.md` | `solve.monte_carlo` | ⬜ |
|
| `monte_carlo.md` | `solve.monte_carlo` | ⬜ |
|
||||||
| `monte_carlo_probability.md` | `solve.monte_carlo_probability` | ⬜ |
|
| `monte_carlo_probability.md` | `solve.monte_carlo_probability` | ⬜ |
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# detect_outliers
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 模块 | `mcm_toolkit.data.clean` |
|
||||||
|
| 档位 | S |
|
||||||
|
| 参考来源 | Tukey (1977) *Exploratory Data Analysis*(1.5·IQR 围栏的出处);3σ 准则见 NIST/SEMATECH e-Handbook §1.3.5.17;实现直接调用 `pandas.DataFrame.quantile` / `mean` / `std` |
|
||||||
|
| 实现状态 | 已实现(2026-10-02),`tests/test_data.py` 中 9 条用例覆盖 |
|
||||||
|
|
||||||
|
## 1. 数学定义
|
||||||
|
|
||||||
|
设某列数值样本 `x_1..x_n`,记 `Q_1 = quantile(0.25)`、`Q_3 = quantile(0.75)`,
|
||||||
|
四分位距 `IQR = Q_3 - Q_1`。对每个 `x_i` 给出布尔判定:
|
||||||
|
|
||||||
|
**IQR 法**(默认):
|
||||||
|
|
||||||
|
```text
|
||||||
|
m_i = (x_i < Q_1 - k·IQR) or (x_i > Q_3 + k·IQR) 默认 k = 1.5
|
||||||
|
```
|
||||||
|
|
||||||
|
**z-score 法**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
z_i = (x_i - x̄) / s s 为样本标准差(ddof = 1)
|
||||||
|
m_i = |z_i| > t 默认 t = 3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
输入:一张表(`DataFrame`)或一维序列;输出:**与原数据同形的布尔掩码**,
|
||||||
|
`True` 表示该位置是离群点。数学上它是逐元素定义的二值函数,不依赖行与行之间的关系。
|
||||||
|
|
||||||
|
## 2. 适用场景
|
||||||
|
|
||||||
|
**用**:
|
||||||
|
|
||||||
|
- 数据录入错误(身高 260 cm、年龄 −10)这类"明显不可能"的点,先标出来给人看。
|
||||||
|
- 建模前的敏感性分析:删与不删各跑一遍,看结论动不动 —— 这比直接删更有说服力。
|
||||||
|
- 论文要交代"异常值怎么处理"时,这是最容易被评审接受的判据(有文献、可复现、无随机性)。
|
||||||
|
|
||||||
|
**不用**:
|
||||||
|
|
||||||
|
- **别拿它当自动清洗**。偏态、幂律、小样本(`n < 20`)时 IQR 围栏会误杀 ——
|
||||||
|
重尾数据里"离群"常常就是信号本身。
|
||||||
|
- z-score 法**前提是近似正态**。右偏数据(收入、人口、成绩分布)用它会把整片上尾标红。
|
||||||
|
- 时间序列里的"离群"可能来自趋势或季节项,应先去趋势/差分再判,否则会把拐点当异常。
|
||||||
|
- 多变量离群(单变量看都正常、组合起来才异常)它看不出来,那要用马氏距离或 `IsolationForest`。
|
||||||
|
|
||||||
|
## 3. 参数与返回值
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认 | 含义 | 取值影响 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `data` | `DataFrame` / `Series` / `list` / `tuple` / `ndarray` | 必填 | 待判数据 | 非数值列不参与统计,掩码里恒 `False`(见下) |
|
||||||
|
| `method` | `"iqr"` / `"zscore"` | `"iqr"` | 判据 | IQR 对重尾稳健;zscore 只在近似正态时可靠 |
|
||||||
|
| `k` | `float` | `1.5` | 仅 IQR:围栏倍数 | 1.5 是 Tukey 的 "outlier"、3.0 是 "far outlier";调小 → 标出更多 |
|
||||||
|
| `threshold` | `float` | `3.0` | 仅 zscore:\|z\| 阈值 | 3.0 ≈ 正态下的 0.27%;调小 → 标出更多 |
|
||||||
|
|
||||||
|
`k` 与 `threshold` 通过 `**kwargs` 传入,**传错了不会报错**(`k` 只在 iqr 分支被读)。
|
||||||
|
|
||||||
|
### 为什么它不返回 `Result`
|
||||||
|
|
||||||
|
模板里"返回 `Result`"这一节对它**不适用**,这是刻意的:`Result` 承载的是"一次计算的
|
||||||
|
参数 + 指标 + 数据",而 `detect_outliers` 不改数据、不产生标量指标,它只是一个逐元素的
|
||||||
|
布尔判断。包成 `Result` 会多一层壳,调用方反而要写 `r.data` 才能拿到掩码。
|
||||||
|
|
||||||
|
返回值按输入形状分派:
|
||||||
|
|
||||||
|
| 输入 | 输出 |
|
||||||
|
|---|---|
|
||||||
|
| `DataFrame` | 同形布尔 `DataFrame` |
|
||||||
|
| `Series` | 布尔 `Series`,**列名保留** |
|
||||||
|
| 一维 `list` / `tuple` / `ndarray` | 一维布尔 `ndarray` |
|
||||||
|
| 二维 `ndarray` | 同形布尔 `DataFrame` |
|
||||||
|
|
||||||
|
### 非数值列为什么恒 `False`
|
||||||
|
|
||||||
|
两条备选路都被否掉了:
|
||||||
|
|
||||||
|
- **跳过**(把非数值列从掩码里去掉)→ docstring 承诺的"与原数据同形"就是假话,
|
||||||
|
形状对不上,也就没法按位置把掩码和原表对回去;
|
||||||
|
- **报错** → 一张带 `city` 列的真题表直接不可用,而 `city` 列本来就不该影响数值列判定。
|
||||||
|
|
||||||
|
`False` 的读法是"这一列没有离群点",对文本列而言这是最不坏的解释,
|
||||||
|
而且让整表能一次跑完。**但要注意**:这只是"不报错",
|
||||||
|
不代表文本列可以当数值列进模型 —— 编码是 `one_hot_encode` 的事。
|
||||||
|
|
||||||
|
## 4. 复杂度
|
||||||
|
|
||||||
|
- 时间:`O(n·p)`(n 行、p 列;分位数和均值各扫一遍,比较再扫一遍)。
|
||||||
|
- 空间:`O(n·p)` —— 掩码与原表同形,这是主要开销。
|
||||||
|
- **什么时候会跑不完**:单机几千万行以上时,同形掩码会把内存吃满
|
||||||
|
(1e7 行 × 20 列 `float64` 约 1.6 GB,掩码再来一份 `bool` 约 0.2 GB,
|
||||||
|
但 `quantile` 内部还会再复制)。真到那一步应该分块流式处理,而不是加内存。
|
||||||
|
|
||||||
|
## 5. 数值稳定性
|
||||||
|
|
||||||
|
- **`std == 0`(整列同值)**:代码里先 `std.replace(0, np.nan)`,让该列 z 全为 NaN、
|
||||||
|
一律判 `False`。少这一步会得到 `inf` / `0/0` 并伴随 RuntimeWarning。
|
||||||
|
- **NaN**:比较结果是 `False`,所以缺失值**不会**被标成离群。这是有意的 ——
|
||||||
|
缺失归 `clean_missing` / `impute_missing` 管,两件事不该混。
|
||||||
|
- **`n = 1`**:`std` 是 NaN、分位数等于该值本身,掩码全 `False`。小样本别指望它。
|
||||||
|
- **边界值**:`x_i` 恰好等于围栏值时**不算**离群(用的是严格 `<` / `>`)。
|
||||||
|
这是刻意选的:边界值不该被当异常。
|
||||||
|
- **`ddof` 口径**:本函数用 pandas 的 `Series.std()`,默认 `ddof = 1`(样本标准差),
|
||||||
|
而 `numpy.std` 默认 `ddof = 0`(总体标准差)。小样本下差别不小:`[1,2,3,4,1000]` 的
|
||||||
|
std 是 446.1(ddof=1)对 399.0(ddof=0),z 值直接差一成。**本项目 `stats.zscore` 的默认
|
||||||
|
也是 `ddof = 0`**,两处口径不一致是已知问题 —— 论文里报 |z| 时必须写明用的哪一个。
|
||||||
|
(分位数这边反而没问题:pandas `quantile` 与 `numpy.percentile` 默认都是线性插值,结果一致。)
|
||||||
|
|
||||||
|
## 6. 常见错误
|
||||||
|
|
||||||
|
1. 把返回值当筛选结果:`df = detect_outliers(df)` → 表变成布尔掩码。
|
||||||
|
正确做法是 `mask = detect_outliers(df)`,然后**自己决定**删不删。
|
||||||
|
2. 对含文本列的真题表直接调用 → 老版本在 `quantile()` 上崩。现在非数值列恒 `False`,
|
||||||
|
但要记得"不崩"不等于"文本列进了模型"。
|
||||||
|
3. 用 z-score 判右偏数据 → 整片上尾被标红,删完结论就变了。先画分布再选方法。
|
||||||
|
4. 看到离群就删 → 论文里没有交代、评审必问。先报告个数与占比,再做删/不删对照。
|
||||||
|
5. 只报"删了 5 个" → 没给比例、没给判据、没给 `k`。论文里这三个数缺一不可。
|
||||||
|
6. 以为要设 seed → 本函数**不含随机成分**;但后续若用 bootstrap 比较"删前后"的差异,
|
||||||
|
那条链必须 `set_seed`,否则"可复现"是假的。
|
||||||
|
|
||||||
|
## 7. 最小示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from mcm_toolkit.data import detect_outliers
|
||||||
|
|
||||||
|
df = pd.DataFrame({"city": list("abcd"), "v": [1.0, 2.0, 3.0, 1000.0]})
|
||||||
|
|
||||||
|
mask = detect_outliers(df) # IQR,默认 k=1.5;只标不删
|
||||||
|
print(mask)
|
||||||
|
# city v
|
||||||
|
# 0 False False
|
||||||
|
# 1 False False
|
||||||
|
# 2 False False
|
||||||
|
# 3 False True
|
||||||
|
|
||||||
|
print(np.asarray(mask).sum()) # 1
|
||||||
|
print(detect_outliers(df["v"]).tolist()) # Series 进 Series 出
|
||||||
|
# [False, False, False, True]
|
||||||
|
|
||||||
|
# 想改成更激进的判定:k 调小
|
||||||
|
print(np.asarray(detect_outliers(df, k=0.5)).sum())
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 单元测试要点
|
||||||
|
|
||||||
|
| 测什么 | 期望 |
|
||||||
|
|---|---|
|
||||||
|
| 埋 3 个远离主体的点(IQR 默认) | 正好命中 3 个,且位置对得上 |
|
||||||
|
| 埋 1 个点(zscore 默认 t=3) | 正好命中 1 个 |
|
||||||
|
| 返回形状 | 与输入同形;Series 进 Series 出且列名保留;一维数组进一维布尔出 |
|
||||||
|
| 含文本列的表 | 不崩,文本列恒 `False`,数值列照常判定 |
|
||||||
|
| 整表没有数值列 | 不崩,返回全 `False` 掩码 |
|
||||||
|
| `method="bogus"` | `ValueError`,报错信息里带方法名 |
|
||||||
|
| `data = 42 / 3.14 / "123" / None / dict` | `TypeError`(§4.7:非法类型不静默兜底) |
|
||||||
|
| 全 NaN 列、单元素列 | 全 `False`,不抛异常 |
|
||||||
|
| 元组入参 | 按一维数组型处理,结果与传 list 一致 |
|
||||||
|
|
||||||
|
## 9. MCM/ICM 用法
|
||||||
|
|
||||||
|
- **哪类题**:所有要写"数据预处理"小节的题(几乎全部)。评价类与预测类尤其要做,
|
||||||
|
因为离群点对均值、回归系数的影响最大。
|
||||||
|
- **论文里怎么表述**:
|
||||||
|
"采用 Tukey (1977) 的 1.5×IQR 围栏识别异常值:记 Q1、Q3 为第一、三四分位数,
|
||||||
|
凡落在 [Q1 − 1.5·IQR, Q3 + 1.5·IQR] 之外者标为可疑点,共 N 个(占 x%)。
|
||||||
|
我们保留全部原始数据,并给出剔除/不剔除两版对照结果(见附录 B)。"
|
||||||
|
- **图**:箱线图(标出被标红的点)+ 直方图叠围栏竖线;用 `viz.plot(kind="box")`
|
||||||
|
与 `viz.plot(kind="hist")`,保存用 `viz.save_figure`。
|
||||||
|
- **踩坑提醒**:评审最可能追问三件事 ——
|
||||||
|
① 为什么是 1.5 而不是 3?② 删了多少、删完结论变了吗?③ 这些点是"错误"还是"真实极端值"?
|
||||||
|
三个问题都要在论文里先答,别等被问。
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""data 包最小用法:读 → 清 → 补 → 判离群 → 缩尾。
|
||||||
|
|
||||||
|
每一步都对应 `tests/test_data.py` 里的一条用例;体量刻意压到最小。
|
||||||
|
`stats.describe`(描述统计)还没实现,最后留了 TODO(L0) 的位置。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
import mcm_toolkit as mcm
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
mcm.set_seed(20260101)
|
||||||
|
|
||||||
|
# 1) 假装这是从 Excel 导出的赛题数据:带文本列、带缺失、带两个录入错误
|
||||||
|
df = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"city": ["A", "B", "C", "D", "E", "F"],
|
||||||
|
"gdp": [1000.0, 1100.0, np.nan, 1300.0, 1400.0, 9999.0],
|
||||||
|
"pop": [10.0, 11.0, np.nan, np.nan, 20.0, 15.0],
|
||||||
|
"notes": ["ok", "ok", np.nan, "ok", np.nan, "异常"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "demo.csv"
|
||||||
|
|
||||||
|
# 2) 写盘再读回:load_table 按扩展名分派,.csv 会自动探测编码
|
||||||
|
mcm.data.save_table(df, path)
|
||||||
|
loaded = mcm.data.load_table(path)
|
||||||
|
print(f"读回 {loaded.shape[0]} 行 × {loaded.shape[1]} 列")
|
||||||
|
|
||||||
|
# 3) 缺失太多的行先删:缺失比例 > 0.5 的走人(4 列,阈值 0.5 -> 至少要 2 个非空)
|
||||||
|
kept = mcm.data.clean_missing(loaded, axis=0, threshold=0.5)
|
||||||
|
print(f"按缺失比例删行:{loaded.shape[0]} -> {kept.shape[0]}")
|
||||||
|
|
||||||
|
# 4) 剩下的缺失做填补。mean 只作用于数值列,notes 列原样保留缺失
|
||||||
|
filled = mcm.data.impute_missing(kept, method="mean")
|
||||||
|
print("填补后每列缺失数:", filled.isna().sum().to_dict())
|
||||||
|
|
||||||
|
# 5) 找离群点:只标不删,删不删由人决定
|
||||||
|
mask = mcm.data.detect_outliers(filled, method="iqr", k=1.5)
|
||||||
|
print("检出离群点:")
|
||||||
|
print(mask)
|
||||||
|
print(f"共 {int(mask.sum().sum())} 个;notes 列恒 False(非数值列不参与统计)")
|
||||||
|
|
||||||
|
# 6) 不删也能用:缩尾把极端值压到分位点上,而不是丢掉整行
|
||||||
|
w = mcm.data.winsorize(filled, lower=0.01, upper=0.99)
|
||||||
|
print(f"gdp 最大值 {filled['gdp'].max():.1f} -> 缩尾后 {w['gdp'].max():.1f}")
|
||||||
|
|
||||||
|
# TODO(L0):stats.describe(filled[["gdp", "pop"]]) 还没实现,实现后在这里加一段描述统计。
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
> **当前状态**:这些脚本是**用法规格**,函数实现填好之前跑不通
|
> **当前状态**:这些脚本是**用法规格**,函数实现填好之前跑不通
|
||||||
> (会抛 `NotImplementedError`)。实现顺序见 `docs/03-roadmap.md`。
|
> (会抛 `NotImplementedError`)。实现顺序见 `docs/03-roadmap.md`。
|
||||||
|
>
|
||||||
|
> `00_quickstart.py` 与 `01_clean_and_describe.py` 用到的函数大多已实现,可以跑;
|
||||||
|
> 两者末尾各有 `TODO(L0)`,等 `stats` / `viz` 补齐。
|
||||||
|
|
||||||
| 文件 | 对应模块 | 覆盖的 L 档 |
|
| 文件 | 对应模块 | 覆盖的 L 档 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -109,12 +109,15 @@ def impute_missing(data: Any, method: str = "mean", **kwargs: Any) -> Any:
|
|||||||
if method == 'median':
|
if method == 'median':
|
||||||
return data.fillna(data.median(numeric_only=True),**kwargs)
|
return data.fillna(data.median(numeric_only=True),**kwargs)
|
||||||
if method == 'mode':
|
if method == 'mode':
|
||||||
mode_df = data.mode()
|
# 只有这一支要逐列赋值,所以在这里单独复制一份,别就地改调用方的表
|
||||||
for col in data.columns:
|
# (其余四支走 fillna / ffill / interpolate,本来就返回新对象)。
|
||||||
|
result = data.copy()
|
||||||
|
mode_df = result.mode()
|
||||||
|
for col in result.columns:
|
||||||
if mode_df[col].empty:
|
if mode_df[col].empty:
|
||||||
continue
|
continue
|
||||||
data[col] = data[col].fillna(mode_df[col].iloc[0])
|
result[col] = result[col].fillna(mode_df[col].iloc[0])
|
||||||
return data
|
return result
|
||||||
if method == 'ffill':
|
if method == 'ffill':
|
||||||
return data.ffill(**kwargs)
|
return data.ffill(**kwargs)
|
||||||
return data.interpolate(**kwargs)
|
return data.interpolate(**kwargs)
|
||||||
@@ -126,7 +129,7 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg
|
|||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
data:
|
data:
|
||||||
`pandas.DataFrame` / `pandas.Series`,或能转成表的数组型。
|
`pandas.DataFrame` / `pandas.Series`,或 `list` / `tuple` / `numpy.ndarray`。
|
||||||
method:
|
method:
|
||||||
`"iqr"`:Q1-1.5·IQR 之外(`k` 可调,默认 1.5);
|
`"iqr"`:Q1-1.5·IQR 之外(`k` 可调,默认 1.5);
|
||||||
`"zscore"`:|z| > threshold(默认 3.0)。
|
`"zscore"`:|z| > threshold(默认 3.0)。
|
||||||
@@ -139,6 +142,8 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg
|
|||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
|
TypeError
|
||||||
|
`data` 既不是 `pandas.DataFrame` / `pandas.Series`,也不是 `list` / `tuple` / `numpy.ndarray`。
|
||||||
ValueError
|
ValueError
|
||||||
`method` 既不是 `"iqr"` 也不是 `"zscore"`。
|
`method` 既不是 `"iqr"` 也不是 `"zscore"`。
|
||||||
|
|
||||||
@@ -156,8 +161,11 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg
|
|||||||
>>> bool(mask[-1])
|
>>> bool(mask[-1])
|
||||||
True
|
True
|
||||||
"""
|
"""
|
||||||
|
if not isinstance(data, (pd.DataFrame, pd.Series, list, tuple, np.ndarray)):
|
||||||
|
raise TypeError(f"data必须是pandas DataFrame/Series 或数组型,你传入了{type(data)}")
|
||||||
|
|
||||||
is_series = isinstance(data, pd.Series)
|
is_series = isinstance(data, pd.Series)
|
||||||
is_ld_array = isinstance(data, (list, np.ndarray)) and np.array(data).ndim == 1
|
is_ld_array = isinstance(data, (list, tuple, 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()
|
||||||
|
|||||||
+35
-5
@@ -238,14 +238,43 @@ 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,实现逐条跟上(剩余 1 条 xfail)
|
def test_detect_outliers_rejects_bad_type():
|
||||||
|
"""§4.7:类型不对必须抛 TypeError,不能让它一路飘进 pandas 里去。"""
|
||||||
|
from mcm_toolkit.data import detect_outliers
|
||||||
|
|
||||||
|
for bad in (42, 3.14, "123", None, {"a": [1, 2]}):
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
detect_outliers(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_outliers_survives_degenerate_columns():
|
||||||
|
"""全 NaN 列与单元素列都不能抛异常,判定一律为 False。"""
|
||||||
|
from mcm_toolkit.data import detect_outliers
|
||||||
|
|
||||||
|
df = pd.DataFrame({"all_nan": [np.nan, np.nan, np.nan], "one": [1.0, 2.0, 3.0]})
|
||||||
|
assert not bool(np.asarray(detect_outliers(df)).any())
|
||||||
|
assert not bool(np.asarray(detect_outliers([5.0])).any())
|
||||||
|
assert not bool(np.asarray(detect_outliers([5.0], method="zscore")).any())
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_outliers_accepts_tuple_as_1d():
|
||||||
|
"""元组按一维数组型处理,返回值与传 list 一致。"""
|
||||||
|
from mcm_toolkit.data import detect_outliers
|
||||||
|
|
||||||
|
mask = np.asarray(detect_outliers((1.0, 2.0, 3.0, 4.0, 1000.0)))
|
||||||
|
assert mask.shape == (5,)
|
||||||
|
assert mask.dtype == bool
|
||||||
|
assert bool(mask[-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 的文本列、detect_outliers 的文本列。剩 1 条不是漏改,是**设计还没定**:
|
# winsorize 的文本列、detect_outliers 的文本列、impute_missing 的 mode 就地修改。
|
||||||
# - impute_missing 的 mode 分支该不该就地改调用方的表(其余分支都返回新表)
|
# **当前 0 条 xfail** —— 以后新增契约照旧:先写测试 + xfail,实现跟上就摘 mark。
|
||||||
|
|
||||||
|
|
||||||
def test_detect_outliers_finds_planted_points():
|
def test_detect_outliers_finds_planted_points():
|
||||||
@@ -315,12 +344,13 @@ def test_clean_missing_rejects_out_of_range_threshold():
|
|||||||
clean_missing(pd.DataFrame({"a": [1.0, np.nan]}), threshold=1.5)
|
clean_missing(pd.DataFrame({"a": [1.0, np.nan]}), threshold=1.5)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reason="mode 分支在调用方的表上就地改,其余分支都返回新表", strict=False)
|
|
||||||
def test_impute_missing_mode_does_not_mutate_input():
|
def test_impute_missing_mode_does_not_mutate_input():
|
||||||
|
"""mode 分支也必须返回新表:不能顺手把调用方的表改了。"""
|
||||||
from mcm_toolkit.data import impute_missing
|
from mcm_toolkit.data import impute_missing
|
||||||
|
|
||||||
df = pd.DataFrame({"a": [1.0, np.nan, 1.0]})
|
df = pd.DataFrame({"a": [1.0, np.nan, 1.0]})
|
||||||
impute_missing(df, method="mode")
|
out = impute_missing(df, method="mode")
|
||||||
|
assert out["a"].tolist() == [1.0, 1.0, 1.0]
|
||||||
assert bool(df["a"].isna().any())
|
assert bool(df["a"].isna().any())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user