feat(data): clean.py 四个函数落地;补测试、docstring 与 ruff 清理
src 由用户本人写(决策 25),Codex 只做审查、补测试与文档。 实现(用户): - clean_missing:按行/列删缺失过多的行,threshold 用 math.ceil 算 min_non_na (3 列、阈值 0.5 时必须是 2;写 int() 截断会漏删) - impute_missing / detect_outliers / winsorize(原属 L1,顺手落地) Codex 改的轻度项: - 按 §4.7 给四个函数补 Parameters / Returns / Raises(与 io.py 同一套 numpydoc 格式) - ruff 6 -> 1:导入分组(I001)、elif 链改 if 加末尾无条件 return(RET505/RET503)、 去掉多余 f 前缀(F541)、删掉只会被覆盖的 mask 初始化、去掉行尾空白(W293) - tests/test_data.py:新增 13 条 clean 行为测试 + 7 条 xfail 结果: - pytest 53 passed / 42 skipped / 7 xfailed(此前 40 / 44 / 0) - pyright 0 errors, 0 warnings - ruff 剩 1 条 SIM102,就在 detect_outliers 里那段待修的代码中,删掉即全绿 已知未修(详见 STATUS.md「下一步」): - detect_outliers 三个 bug:threshold 范围校验放错函数(iqr 分支 UnboundLocalError、 zscore 默认 3.0 被 [0,1] 拒掉)、quantile 在含文本列的表上崩、 is_series / is_ld_array 在 data 已转 DataFrame 之后才判断,因此永远为 False - winsorize 同样在含文本列的表上崩 - impute_missing 的 mode 分支就地修改调用方的表
This commit is contained in:
1 parent
97a2f95066
commit
c2595b6d07
4 files changed
+414
-36
No files matched your search
+21
-2
@@ -40,8 +40,19 @@
|
|||||||
非法类型必须抛 `TypeError`。配套在 `docs/03-roadmap.md` 的开发纪律里加了指针。
|
非法类型必须抛 `TypeError`。配套在 `docs/03-roadmap.md` 的开发纪律里加了指针。
|
||||||
- `docs/dev/personal-pitfalls.md`:个人易错点清单,**只追加、不通读**
|
- `docs/dev/personal-pitfalls.md`:个人易错点清单,**只追加、不通读**
|
||||||
|
|
||||||
### Fixed
|
### Added
|
||||||
- 编辑器 / pyright 报"无法解析导入 numpy、pandas":分析器会去 PATH 上找 python,
|
- `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
|
||||||
|
(Codex 代写,与 io.py 同一套 numpydoc 格式)。
|
||||||
|
- `data.clean`:ruff 从 6 条清到 1 条——导入分组(I001)、elif 链改 if 加末尾无条件 return
|
||||||
|
(RET505 / RET503)、去掉多余 f 前缀(F541)、删掉只会被覆盖的 mask 初始化(死代码)、
|
||||||
|
去掉行尾空白(W293)。剩下的 1 条 SIM102 就在下面 Notes 那条待修的代码里,删掉它即全绿。:分析器会去 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"` /
|
||||||
@@ -62,6 +73,14 @@
|
|||||||
已把残骸目录改名禁用(**可逆**,未删除),详见 `docs/04-environment.md` 坑 6。
|
已把残骸目录改名禁用(**可逆**,未删除),详见 `docs/04-environment.md` 坑 6。
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
- `detect_outliers` **目前不可用**,三个 bug 已在 `tests/test_data.py` 里用 xfail 标出:
|
||||||
|
threshold 范围校验放错了函数(iqr 分支 UnboundLocalError、zscore 分支默认 3.0 被 0<=t<=1 拒掉)、
|
||||||
|
quantile 在含文本列的表上直接崩、is_series / is_ld_array 在数据已转成 DataFrame 之后才判断
|
||||||
|
因此永远为 False(返回形状仍是 (n,1),与 docstring 承诺的「与原数据同形」不符)。
|
||||||
|
修法与优先级见 `STATUS.md` 的「下一步」。
|
||||||
|
- `winsorize` 与 `detect_outliers` 都还没能在含文本列的赛题表上跑通:分位数必须限制在数值列。
|
||||||
|
- `impute_missing` 的 mode 分支会**就地修改**调用方的 DataFrame,其余四个分支都返回新表。
|
||||||
|
- 已知缺口:
|
||||||
- 除 `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 顺序填实现。
|
||||||
- 已知缺口:`SALib`(Sobol/Morris)、`pymoo`、`pyright` 已装进 conda 环境 `mcm-toolkit`,`.venv` 里没有。
|
- 已知缺口:`SALib`(Sobol/Morris)、`pymoo`、`pyright` 已装进 conda 环境 `mcm-toolkit`,`.venv` 里没有。
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
- [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`),
|
- [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`),
|
||||||
`~/.ssh/config` 加了一条域名别名,remote 里仍写域名
|
`~/.ssh/config` 加了一条域名别名,remote 里仍写域名
|
||||||
|
|
||||||
**E. L0 实现进度(3/12)**
|
**E. L0 实现进度(4/12)**
|
||||||
- [x] `core/result.py` 的 `Result`
|
- [x] `core/result.py` 的 `Result`
|
||||||
- `__repr__`:单行紧凑;`data` 只给类型与规模,**绝不**整数组打印
|
- `__repr__`:单行紧凑;`data` 只给类型与规模,**绝不**整数组打印
|
||||||
(十万级数组的 repr 实测仍 < 200 字符);`success` 恒在
|
(十万级数组的 repr 实测仍 < 200 字符);`success` 恒在
|
||||||
@@ -90,7 +90,13 @@
|
|||||||
- 测试:`tests/test_data.py` 由 1 条(且被模块级 skip 掉)扩到 **9 条**
|
- 测试:`tests/test_data.py` 由 1 条(且被模块级 skip 掉)扩到 **9 条**
|
||||||
- docstring 的 `Parameters` / `Raises` 已按 §4.7 补齐(Codex 代写,用户授权)——
|
- docstring 的 `Parameters` / `Raises` 已按 §4.7 补齐(Codex 代写,用户授权)——
|
||||||
numpydoc 格式,对齐参考实现 `core/random.py` 的 `get_rng`
|
numpydoc 格式,对齐参考实现 `core/random.py` 的 `get_rng`
|
||||||
- [ ] 剩余 9 项(10 个函数):`clean_missing`
|
- [x] `data/clean.py` 的 `clean_missing`(L0 第 4 项)
|
||||||
|
- 实现由**用户本人**写,Codex 只做审查、补测试与文档
|
||||||
|
- `threshold` 用 `math.ceil` 算 `min_non_na`:3 列、阈值 0.5 时必须是 2,
|
||||||
|
写 `int()` 截断会漏删
|
||||||
|
- 顺手落地兄弟函数 `impute_missing` / `winsorize`(原属 L1)
|
||||||
|
- [ ] **待修:** `detect_outliers` 已写但有 3 个 bug(见下)
|
||||||
|
- [ ] 剩余 7 项:`normalize`
|
||||||
`detect_outliers` `normalize` `standardize` `describe` `correlation_matrix`
|
`detect_outliers` `normalize` `standardize` `describe` `correlation_matrix`
|
||||||
`bootstrap_ci` `monte_carlo` `save_figure`
|
`bootstrap_ci` `monte_carlo` `save_figure`
|
||||||
|
|
||||||
@@ -98,19 +104,21 @@
|
|||||||
|
|
||||||
| 检查 | venv | conda `mcm-toolkit` |
|
| 检查 | venv | conda `mcm-toolkit` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `pytest` | **40 passed, 44 skipped** | **40 passed, 44 skipped** |
|
| `pytest` | 见下(单列) | **53 passed, 42 skipped, 7 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 |
|
| `ruff check` | All checks passed | **1 error**(就在 `detect_outliers` 那段待修的代码里) |
|
||||||
| `scripts/doctor.ps1` | 跑通 | — |
|
| `scripts/doctor.ps1` | 跑通 | — |
|
||||||
| SALib / pymoo / pyright | 缺失 | 已装 |
|
| SALib / pymoo / pyright | 缺失 | 已装 |
|
||||||
|
|
||||||
- `pyright`(分析目标 = 工作区 `.venv`):**0 errors, 0 warnings**
|
- `pyright`(分析目标 = 工作区 `.venv`):**0 errors, 0 warnings**
|
||||||
(修配置之前是 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(与两套环境一致)
|
||||||
- skip 的都是等实现的算法行为测试;通过的 39 条 = 20 条公开 API 契约测试
|
- 53 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 22 条 `data` 行为测试
|
||||||
(`tests/test_api_surface.py`)+ 11 条 `core` 行为测试(`tests/test_core.py`)
|
- 7 条 `xfail` 是**已知待修**的契约(`detect_outliers` 的 4 条、`winsorize` 混合类型、
|
||||||
+ 9 条 `data` 行为测试(`tests/test_data.py`)
|
`clean_missing` 阈值范围、`impute_missing` 的 mode 就地修改);
|
||||||
|
修好后会自动变 XPASS,那时把 `xfail` 摘掉即可(决策 30)
|
||||||
|
- 42 条 skip 是等实现的算法(`normalize` / `standardize` / `train_test_split` 等)
|
||||||
- 三个 `.ps1` 通过 PowerShell AST 语法解析
|
- 三个 `.ps1` 通过 PowerShell AST 语法解析
|
||||||
|
|
||||||
### ⚠️ 需要注意 / 遗留
|
### ⚠️ 需要注意 / 遗留
|
||||||
@@ -120,7 +128,7 @@
|
|||||||
只差 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 完成 **3/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进;
|
- [ ] L0 完成 **4/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进;
|
||||||
详情见上面 E 节
|
详情见上面 E 节
|
||||||
- [ ] 本机 `exec_command` 在沙箱模式下**完全不可用**
|
- [ ] 本机 `exec_command` 在沙箱模式下**完全不可用**
|
||||||
(`helper_unknown_error: setup refresh had errors`),只有提权
|
(`helper_unknown_error: setup refresh had errors`),只有提权
|
||||||
@@ -162,17 +170,29 @@
|
|||||||
| 24 | `get_rng(None)` **复用同一个模块级生成器**(会推进),不每次新建 | **用户本人提出**,取代 Codex 原来"每次新建 `default_rng(全局种子)`"的写法——那样两个都省略 `random_state` 的函数会抽到完全相同的数。共享单例既保证重跑一致,又让流自然推进 |
|
| 24 | `get_rng(None)` **复用同一个模块级生成器**(会推进),不每次新建 | **用户本人提出**,取代 Codex 原来"每次新建 `default_rng(全局种子)`"的写法——那样两个都省略 `random_state` 的函数会抽到完全相同的数。共享单例既保证重跑一致,又让流自然推进 |
|
||||||
| 25 | `src/` 下的实现默认由**用户本人**写,Codex 只改 `tests/` 与 `docs/` | 目标是"一边学一边自己写"。曾出现 Codex 直接落盘 `random.py`、与用户编辑器缓冲区冲突导致保存失败。需要代写时用户会明说 |
|
| 25 | `src/` 下的实现默认由**用户本人**写,Codex 只改 `tests/` 与 `docs/` | 目标是"一边学一边自己写"。曾出现 Codex 直接落盘 `random.py`、与用户编辑器缓冲区冲突导致保存失败。需要代写时用户会明说 |
|
||||||
| 26 | 未实现的算法用**逐条** `@pytest.mark.skip`,禁用模块级 `pytestmark` | 模块级 skip 会把同文件里**已经写好**的函数一起跳过。本次 `test_data.py` 就这样让 `load_table` 零覆盖,直到有人手动数才被发现 |
|
| 26 | 未实现的算法用**逐条** `@pytest.mark.skip`,禁用模块级 `pytestmark` | 模块级 skip 会把同文件里**已经写好**的函数一起跳过。本次 `test_data.py` 就这样让 `load_table` 零覆盖,直到有人手动数才被发现 |
|
||||||
|
| 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 |
|
||||||
| 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. ~~`save_table` 校验顺序~~ **已修**:后缀判断提到 `mkdir` 前面,并补了回归测试
|
1. **修 `detect_outliers`**(唯一挡在 L0 前进路上的 🔴,三处都在 `src/mcm_toolkit/data/clean.py`):
|
||||||
`test_save_table_validates_before_touching_disk`(断言参数非法时 `tmp_path` 下一个条目都不留)。
|
- `clean.py:165-167` 的 `threshold` 范围校验放错了函数:`threshold` 只在 zscore 分支
|
||||||
`io.py` 至此收尾,L0 的第 3 项完成。
|
赋值,iqr 走进去就是 `UnboundLocalError`;而且 z 的阈值本来就是 3.0,
|
||||||
2. 继续 L0,**下一个:`data/clean.py` 的 `clean_missing`**(L0 / A 档,直接能写)。
|
用 `0 <= threshold <= 1` 卡它必然把默认值拒掉。
|
||||||
同文件的 `detect_outliers` 是 **S 档**,要先按 `docs/dev/function-card-template.md`
|
你本意多半是想校验 `clean_missing` 的 `threshold`(那个才该是 [0,1])——把它挪过去,
|
||||||
把函数卡片写出来再动手。
|
`detect_outliers` 里删掉。ruff 剩下的那 1 条(SIM102)也在这 3 行里,删掉就全绿。
|
||||||
3. 每写完一个函数,在 `examples/` 里加一行最小用法,并更新本文件。
|
- `clean.py:145` 的 `df.quantile()` 要在**数值列**上算:`df.select_dtypes(include='number')`,
|
||||||
4. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
否则赛题表一带文本列就崩。`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`)。
|
||||||
|
roadmap 要求 S 档先写卡片再写码 —— 这次是反着来的,所以三个 bug 都在"该想清楚"的地方翻车:
|
||||||
|
上界符号、布尔优先级、维度。卡片本身就是防这个的。
|
||||||
|
3. 顺手把 `impute_missing` 的 mode 分支改成**返回新表**(现在就地改调用方的 DataFrame,和另外四个分支不一致)。
|
||||||
|
4. 每写完一个函数,在 `examples/` 里加一行最小用法。
|
||||||
|
5. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
||||||
+180
-12
@@ -7,8 +7,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
__all__ = ["clean_missing", "detect_outliers", "impute_missing", "winsorize"]
|
__all__ = ["clean_missing", "detect_outliers", "impute_missing", "winsorize"]
|
||||||
|
|
||||||
|
|
||||||
@@ -17,17 +21,46 @@ def clean_missing(data: Any, axis: int = 0, threshold: float | None = None) -> A
|
|||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
|
data:
|
||||||
|
要清洗的表,必须是 `pandas.DataFrame`。
|
||||||
axis:
|
axis:
|
||||||
0 = 按行删,1 = 按列删。
|
`0` = 按行删,`1` = 按列删。
|
||||||
threshold:
|
threshold:
|
||||||
缺失比例超过该值就删;`None` 表示只要有缺失就删。
|
缺失比例**超过**该值就删,取值 0–1;`None` 表示只要有缺失就删。
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
pandas.DataFrame
|
||||||
|
清洗后的新表(不改原表)。
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TypeError
|
||||||
|
`data` 不是 `pandas.DataFrame`。
|
||||||
|
ValueError
|
||||||
|
`axis` 既不是 0 也不是 1。
|
||||||
|
|
||||||
Examples
|
Examples
|
||||||
--------
|
--------
|
||||||
>>> clean_missing(df, threshold=0.5) # doctest: +SKIP
|
>>> clean_missing(df, threshold=0.5) # doctest: +SKIP
|
||||||
"""
|
"""
|
||||||
# TODO(L0)
|
if axis == 0:
|
||||||
raise NotImplementedError
|
target_axis = 'index'
|
||||||
|
elif axis == 1:
|
||||||
|
target_axis = 'columns'
|
||||||
|
else:
|
||||||
|
raise ValueError(f"axis参数只能是 0 或 1,你传入了{axis}")
|
||||||
|
|
||||||
|
if not isinstance(data,pd.DataFrame):
|
||||||
|
raise TypeError(f"data必须是pandas DataFrame,你传入了{type(data)}")
|
||||||
|
|
||||||
|
if threshold is None:
|
||||||
|
return data.dropna(axis=target_axis,how='any')
|
||||||
|
|
||||||
|
total_len = data.shape[1 -axis]
|
||||||
|
min_non_na = math.ceil(total_len * (1 - threshold))
|
||||||
|
|
||||||
|
return data.dropna(axis=target_axis,thresh=min_non_na)
|
||||||
|
|
||||||
|
|
||||||
def impute_missing(data: Any, method: str = "mean", **kwargs: Any) -> Any:
|
def impute_missing(data: Any, method: str = "mean", **kwargs: Any) -> Any:
|
||||||
@@ -35,16 +68,53 @@ def impute_missing(data: Any, method: str = "mean", **kwargs: Any) -> Any:
|
|||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
|
data:
|
||||||
|
要填补的表,必须是 `pandas.DataFrame`。
|
||||||
method:
|
method:
|
||||||
`"mean"` / `"median"` / `"mode"` / `"ffill"` / `"interpolate"` / `"knn"`。
|
`"mean"` / `"median"` / `"mode"` / `"ffill"` / `"interpolate"`。
|
||||||
|
**kwargs:
|
||||||
|
原样转交给底层的 `fillna` / `ffill` / `interpolate`。
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
pandas.DataFrame
|
||||||
|
填补后的表。
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TypeError
|
||||||
|
`data` 不是 `pandas.DataFrame`。
|
||||||
|
ValueError
|
||||||
|
`method` 不在支持列表里(报错信息会列出全部支持的方法)。
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
-----
|
-----
|
||||||
论文里必须说明用了哪种填补、为什么。填 `mean` 之前先想一下
|
论文里必须说明用了哪种填补、为什么。填 `mean` 之前先想一下
|
||||||
"这个缺失是不是非随机缺失(MNAR)",否则结论可能被填补方式带偏。
|
"这个缺失是不是非随机缺失(MNAR)",否则结论可能被填补方式带偏。
|
||||||
"""
|
"""
|
||||||
# TODO(L0)
|
if not isinstance(data,pd.DataFrame):
|
||||||
raise NotImplementedError
|
raise TypeError(f"data必须是pandas DataFrame,你传入了{type(data)}")
|
||||||
|
|
||||||
|
supported_method = ['mean','median','mode','ffill','interpolate']
|
||||||
|
if method not in supported_method:
|
||||||
|
raise ValueError(f"不支持的填补方式:{method},仅支持:{supported_method}")
|
||||||
|
|
||||||
|
# 上面已校验 method 合法,所以每条 return 之后不必再写 elif;
|
||||||
|
# 最后一行无条件 return 兜住仅剩的 interpolate,函数就没有"隐式返回 None"的路径了。
|
||||||
|
if method == 'mean':
|
||||||
|
return data.fillna(data.mean(numeric_only=True),**kwargs)
|
||||||
|
if method == 'median':
|
||||||
|
return data.fillna(data.median(numeric_only=True),**kwargs)
|
||||||
|
if method == 'mode':
|
||||||
|
mode_df = data.mode()
|
||||||
|
for col in data.columns:
|
||||||
|
if mode_df[col].empty:
|
||||||
|
continue
|
||||||
|
data[col] = data[col].fillna(mode_df[col].iloc[0])
|
||||||
|
return data
|
||||||
|
if method == 'ffill':
|
||||||
|
return data.ffill(**kwargs)
|
||||||
|
return data.interpolate(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwargs: Any) -> Any:
|
def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwargs: Any) -> Any:
|
||||||
@@ -52,14 +122,23 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg
|
|||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
|
data:
|
||||||
|
`pandas.DataFrame` / `pandas.Series`,或能转成表的数组型。
|
||||||
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)。
|
||||||
|
**kwargs:
|
||||||
|
给对应方法用的参数:`k` 只对 `"iqr"` 生效,`threshold` 只对 `"zscore"` 生效。
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
与原数据同形的布尔 DataFrame/ndarray,`True` 表示该点是离群点。
|
与原数据同形的布尔 DataFrame/ndarray,`True` 表示该点是离群点。
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValueError
|
||||||
|
`method` 既不是 `"iqr"` 也不是 `"zscore"`。
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
-----
|
-----
|
||||||
**只标不删**是刻意的:删点是有代价的判断,必须由人来做,
|
**只标不删**是刻意的:删点是有代价的判断,必须由人来做,
|
||||||
@@ -71,11 +150,100 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg
|
|||||||
>>> bool(mask[-1])
|
>>> bool(mask[-1])
|
||||||
True
|
True
|
||||||
"""
|
"""
|
||||||
# TODO(L0) ★ S 档:配完整函数卡片
|
if isinstance(data,pd.Series):
|
||||||
raise NotImplementedError
|
data = data.to_frame()
|
||||||
|
elif not isinstance(data,pd.DataFrame):
|
||||||
|
data = pd.DataFrame(data)
|
||||||
|
|
||||||
|
if method == 'iqr':
|
||||||
|
k = kwargs.get("k", 1.5)
|
||||||
|
elif method == 'zscore':
|
||||||
|
threshold = kwargs.get("threshold", 3.0)
|
||||||
|
else:
|
||||||
|
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':
|
||||||
|
Q1 = df.quantile(0.25)
|
||||||
|
Q3 = df.quantile(0.75)
|
||||||
|
IQR = Q3 - Q1
|
||||||
|
lower_bound = Q1 - k * IQR
|
||||||
|
upper_bound = Q3 + k * IQR
|
||||||
|
|
||||||
|
mask = (df < lower_bound) | (df > upper_bound)
|
||||||
|
|
||||||
|
elif method == 'zscore':
|
||||||
|
mean = df.mean()
|
||||||
|
std = df.std()
|
||||||
|
|
||||||
|
std = std.replace(0, np.nan)
|
||||||
|
z_scores = (df - mean) / std
|
||||||
|
mask = z_scores.abs() > threshold
|
||||||
|
|
||||||
|
if is_series:
|
||||||
|
return mask.iloc[:,0]
|
||||||
|
if is_ld_array:
|
||||||
|
return mask.values.flatten()
|
||||||
|
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
def winsorize(data: Any, lower: float = 0.01, upper: float = 0.99) -> Any:
|
def winsorize(data: Any, lower: float = 0.01, upper: float = 0.99) -> Any:
|
||||||
"""缩尾:把超出分位数的值压到分位点,而不是删除。"""
|
"""缩尾:把超出分位数的值压到分位点,而不是删除。
|
||||||
# TODO(L1)
|
|
||||||
raise NotImplementedError
|
Parameters
|
||||||
|
----------
|
||||||
|
data:
|
||||||
|
`pandas.DataFrame` 或 `pandas.Series`。
|
||||||
|
lower:
|
||||||
|
下分位点,默认 0.01(1%)。
|
||||||
|
upper:
|
||||||
|
上分位点,默认 0.99(99%)。
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
pandas.DataFrame | pandas.Series
|
||||||
|
与输入同类型,超出分位点的值被压到分位点上。
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TypeError
|
||||||
|
`data` 既不是 `pandas.DataFrame` 也不是 `pandas.Series`。
|
||||||
|
ValueError
|
||||||
|
分位数不满足 `0 < lower < upper < 1`。
|
||||||
|
"""
|
||||||
|
if not isinstance(data, (pd.DataFrame, pd.Series)):
|
||||||
|
raise TypeError(f"data必须是pandas DataFrame 或 Series,你传入了{type(data)}")
|
||||||
|
|
||||||
|
if not (0 < lower < upper < 1):
|
||||||
|
raise ValueError(f"分位数必须满足0 < lower < upper < 1,你传入了lower={lower},upper={upper}")
|
||||||
|
|
||||||
|
if isinstance(data,pd.Series):
|
||||||
|
lower_val = data.quantile(lower)
|
||||||
|
upper_val = data.quantile(upper)
|
||||||
|
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
|
||||||
|
result = data.copy()
|
||||||
|
|
||||||
|
if len(numeric_cols) == 0:
|
||||||
|
return data.copy()
|
||||||
|
|
||||||
|
result[numeric_cols] = result[numeric_cols].clip(
|
||||||
|
lower=lower_bounds[numeric_cols],
|
||||||
|
upper=upper_bounds[numeric_cols],
|
||||||
|
axis=1
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
+176
-5
@@ -119,21 +119,143 @@ def test_save_table_validates_before_touching_disk(tmp_path):
|
|||||||
assert sorted(p.name for p in tmp_path.iterdir()) == []
|
assert sorted(p.name for p in tmp_path.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------- 以下为未实现算法(L0/L1 待写)
|
# --------------------------------------------------------------- data: clean
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
def test_clean_missing_default_drops_any_missing():
|
||||||
|
"""不传 threshold 时是"只要有缺失就删"。"""
|
||||||
|
from mcm_toolkit.data import clean_missing
|
||||||
|
|
||||||
|
df = pd.DataFrame({"a": [1.0, np.nan, 3.0], "b": [1.0, 2.0, np.nan]})
|
||||||
|
out = clean_missing(df)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert list(out.columns) == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_missing_threshold_keeps_rows_within_limit():
|
||||||
|
"""四行三列、阈值 0.5:缺 2/3 的两行要删,剩 2 行。
|
||||||
|
|
||||||
|
3 列时 min_non_na 必须是 ceil(3*0.5)=2;写成 int() 截断就会漏删。
|
||||||
|
"""
|
||||||
|
from mcm_toolkit.data import clean_missing
|
||||||
|
|
||||||
|
df = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"a": [1.0, np.nan, np.nan, 4.0],
|
||||||
|
"b": [1.0, np.nan, np.nan, 4.0],
|
||||||
|
"c": [1.0, 2.0, np.nan, 4.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out = clean_missing(df, axis=0, threshold=0.5)
|
||||||
|
assert len(out) == 2
|
||||||
|
assert out["a"].tolist() == [1.0, 4.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_missing_axis1_drops_empty_column():
|
||||||
|
"""axis=1 按列删:整列全空(缺失率 1.0 > 0.5)要掉。"""
|
||||||
|
from mcm_toolkit.data import clean_missing
|
||||||
|
|
||||||
|
df = pd.DataFrame({"x": [1.0, 2.0, 3.0], "y": [np.nan, np.nan, np.nan]})
|
||||||
|
assert list(clean_missing(df, axis=1, threshold=0.5).columns) == ["x"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_missing_rejects_bad_axis():
|
||||||
|
from mcm_toolkit.data import clean_missing
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="axis"):
|
||||||
|
clean_missing(pd.DataFrame({"a": [1.0]}), axis=2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_missing_rejects_non_dataframe():
|
||||||
|
from mcm_toolkit.data import clean_missing
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
clean_missing([[1.0, 2.0]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_impute_missing_mean_and_ffill():
|
||||||
|
from mcm_toolkit.data import impute_missing
|
||||||
|
|
||||||
|
df = pd.DataFrame({"a": [1.0, np.nan, 3.0]})
|
||||||
|
assert impute_missing(df, method="mean")["a"].tolist() == [1.0, 2.0, 3.0]
|
||||||
|
assert impute_missing(df, method="ffill")["a"].tolist() == [1.0, 1.0, 3.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_impute_missing_mode_survives_all_nan_column():
|
||||||
|
"""整列全 NaN 时 mode() 给不出众数:不能崩,也不能硬填一个值进去。"""
|
||||||
|
from mcm_toolkit.data import impute_missing
|
||||||
|
|
||||||
|
df = pd.DataFrame({"a": [np.nan, np.nan], "b": [1.0, np.nan]})
|
||||||
|
out = impute_missing(df, method="mode")
|
||||||
|
assert bool(out["a"].isna().all())
|
||||||
|
assert out["b"].tolist() == [1.0, 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_impute_missing_rejects_unknown_method():
|
||||||
|
from mcm_toolkit.data import impute_missing
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="bogus"):
|
||||||
|
impute_missing(pd.DataFrame({"a": [1.0]}), method="bogus")
|
||||||
|
|
||||||
|
|
||||||
|
def test_impute_missing_rejects_non_dataframe():
|
||||||
|
from mcm_toolkit.data import impute_missing
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
impute_missing([[1.0, 2.0]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_winsorize_clips_series_to_quantiles():
|
||||||
|
from mcm_toolkit.data import winsorize
|
||||||
|
|
||||||
|
s = pd.Series([1.0, 2.0, 3.0, 4.0, 1000.0])
|
||||||
|
out = winsorize(s, lower=0.1, upper=0.9)
|
||||||
|
assert float(out.iloc[0]) == pytest.approx(1.4)
|
||||||
|
assert float(out.iloc[-1]) == pytest.approx(601.6)
|
||||||
|
assert float(out.max()) < 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_winsorize_rejects_bad_quantile_order():
|
||||||
|
from mcm_toolkit.data import winsorize
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="lower"):
|
||||||
|
winsorize(pd.Series([1.0, 2.0]), lower=0.9, upper=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_winsorize_rejects_non_frame_with_typeerror():
|
||||||
|
"""§4.7:类型不对必须抛 TypeError,不能抛 ValueError。"""
|
||||||
|
from mcm_toolkit.data import winsorize
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
winsorize([1.0, 2.0, 3.0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_outliers_rejects_unknown_method():
|
||||||
|
"""方法名校验在取随机取值之前,所以这条现在就是绿的。"""
|
||||||
|
from mcm_toolkit.data import detect_outliers
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="bogus"):
|
||||||
|
detect_outliers([1.0, 2.0], method="bogus") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------- 待修:契约已写进 docstring,实现还没跟上(xfail)
|
||||||
|
#
|
||||||
|
# 这些用例写的是**文档里已经承诺的行为**。现在跑是红的,用 xfail 让它们照样出现在
|
||||||
|
# 报告里(显示成 x);实现修好之后会自动变成 XPASS —— 看到 XPASS 就把 mark 摘掉。
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
x = np.concatenate([np.arange(100.0), [1e6, -1e6, 1e7]])
|
x = np.concatenate([np.arange(100.0), [1e6, -1e6, 1e7]])
|
||||||
mask = np.asarray(detect_outliers(x, method="iqr"))
|
mask = np.asarray(detect_outliers(x, method="iqr"))
|
||||||
assert mask.sum() == 3
|
assert int(mask.sum()) == 3
|
||||||
assert mask[-3:].all()
|
assert bool(mask[-3:].all())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
@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
|
||||||
@@ -144,6 +266,55 @@ 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():
|
||||||
|
from mcm_toolkit.data import detect_outliers
|
||||||
|
|
||||||
|
x = np.concatenate([np.zeros(99), [1e6]])
|
||||||
|
mask = np.asarray(detect_outliers(x, method="zscore"))
|
||||||
|
assert int(mask.sum()) == 1
|
||||||
|
assert bool(mask[-1].all())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail(reason="quantile 在全表上算,遇到文本列直接崩", strict=False)
|
||||||
|
def test_detect_outliers_survives_text_columns():
|
||||||
|
"""赛题表几乎都带文本列,分位数只能在数值列上算。"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail(reason="quantile 在全表上算,遇到文本列直接崩", strict=False)
|
||||||
|
def test_winsorize_survives_text_columns():
|
||||||
|
from mcm_toolkit.data import winsorize
|
||||||
|
|
||||||
|
df = pd.DataFrame({"city": ["a", "b", "c", "d"], "v": [1.0, 2.0, 3.0, 1000.0]})
|
||||||
|
out = winsorize(df)
|
||||||
|
assert out["city"].tolist() == ["a", "b", "c", "d"]
|
||||||
|
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():
|
||||||
|
from mcm_toolkit.data import clean_missing
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="threshold"):
|
||||||
|
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():
|
||||||
|
from mcm_toolkit.data import impute_missing
|
||||||
|
|
||||||
|
df = pd.DataFrame({"a": [1.0, np.nan, 1.0]})
|
||||||
|
impute_missing(df, method="mode")
|
||||||
|
assert bool(df["a"].isna().any())
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- 以下为未实现算法(L0/L1 待写)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
||||||
def test_normalize_minmax_range(rng):
|
def test_normalize_minmax_range(rng):
|
||||||
from mcm_toolkit.data import normalize
|
from mcm_toolkit.data import normalize
|
||||||
|
|||||||
Reference in new issue
Block a user