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:
guzhujushi committed 2026-10-02 15:04:13 +08:00
1 parent 97a2f95066
commit c2595b6d07
4 files changed
+414 -36

No files matched your search

+180 -12
View File
@@ -7,8 +7,12 @@
from __future__ import annotations
import math
from typing import Any, Literal
import numpy as np
import pandas as pd
__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
----------
data:
要清洗的表,必须是 `pandas.DataFrame`。
axis:
0 = 按行删,1 = 按列删。
`0` = 按行删,`1` = 按列删。
threshold:
缺失比例超过该值就删;`None` 表示只要有缺失就删。
缺失比例**超过**该值就删,取值 0–1;`None` 表示只要有缺失就删。
Returns
-------
pandas.DataFrame
清洗后的新表(不改原表)。
Raises
------
TypeError
`data` 不是 `pandas.DataFrame`。
ValueError
`axis` 既不是 0 也不是 1。
Examples
--------
>>> clean_missing(df, threshold=0.5) # doctest: +SKIP
"""
# TODO(L0)
raise NotImplementedError
if axis == 0:
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:
@@ -35,16 +68,53 @@ def impute_missing(data: Any, method: str = "mean", **kwargs: Any) -> Any:
Parameters
----------
data:
要填补的表,必须是 `pandas.DataFrame`。
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
-----
论文里必须说明用了哪种填补、为什么。填 `mean` 之前先想一下
"这个缺失是不是非随机缺失(MNAR)",否则结论可能被填补方式带偏。
"""
# TODO(L0)
raise NotImplementedError
if not isinstance(data,pd.DataFrame):
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:
@@ -52,14 +122,23 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg
Parameters
----------
data:
`pandas.DataFrame` / `pandas.Series`,或能转成表的数组型。
method:
`"iqr"`:Q1-1.5·IQR 之外(`k` 可调,默认 1.5);
`"zscore"`:|z| > threshold(默认 3.0)。
**kwargs:
给对应方法用的参数:`k` 只对 `"iqr"` 生效,`threshold` 只对 `"zscore"` 生效。
Returns
-------
与原数据同形的布尔 DataFrame/ndarray,`True` 表示该点是离群点。
Raises
------
ValueError
`method` 既不是 `"iqr"` 也不是 `"zscore"`。
Notes
-----
**只标不删**是刻意的:删点是有代价的判断,必须由人来做,
@@ -71,11 +150,100 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg
>>> bool(mask[-1])
True
"""
# TODO(L0) ★ S 档:配完整函数卡片
raise NotImplementedError
if isinstance(data,pd.Series):
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:
"""缩尾:把超出分位数的值压到分位点,而不是删除。"""
# 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