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
+180
-12
@@ -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
|
||||
Reference in new issue
Block a user