feat(stats): describe 落地,修掉一个静默填 NaN 的 bug(L0 8/12)
src/stats/descriptive.py 由用户本人实现,这里做审查 + 代修。
describe:
- 真 bug:df_num.describe().T 把表转反(行=变量、列=指标),而 docstring 与
test_describe_reports_expected_keys 定的是"行=指标、列=变量"。转置后
.loc[指标名] = <按变量索引的 Series> 与列名零交集,pandas 不报错,直接把
missing / missing_ratio / skewness / kurtosis 四行整行填成 NaN(静默丢数据);
weights 分支则报 cannot set a row with mismatched columns。去掉 .T 一处修好三件事
- 补 median 行(pandas 只有 50%,测试契约定的是 median)
- 入参放宽到 list / tuple / ndarray(00_quickstart.py 传的就是 ndarray)
- weights 校验补齐:维度 / 长度 / NaN / 负值 / 和为 0(原先和为 0 时裸抛 ZeroDivisionError)
- docstring 按 4.7 补 Parameters / Returns / Raises
zscore: 修 RET505;docstring 补齐;DataFrame 取列处按坑 8 用 cast 收窄
coefficient_of_variation: 均值为 0 改抛 ValueError(取值问题不是类型问题);
空数组提前报错(原先静默 nan + RuntimeWarning);错别字与文案修正
tests: 去掉 test_stats.py 的模块级 pytestmark —— 它把 6 条测试全部跳过(含已实现的
describe),正是决策 26 要禁的写法。改为逐条 skip 未实现的 5 条,并新增 20 条用例。
验证:pytest 102 passed / 39 skipped(原 82/40);ruff All checks passed;pyright 0 errors。
examples/01_clean_and_describe.py 填掉 TODO(L0),实测 exit 0。
docs: STATUS.md L0 8/12 + 决策 35/36 + 三处待拍板口径;CHANGELOG 记录;
personal-pitfalls 追加第 11 条(pandas 对不齐就静默填 NaN)
This commit is contained in:
1 parent
4552aee41c
commit
e9624cb3a7
6 files changed
+468
-34
No files matched your search
@@ -6,35 +6,218 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
__all__ = ["coefficient_of_variation", "describe", "zscore"]
|
||||
|
||||
|
||||
def describe(x: Any, weights: Any | None = None) -> Any:
|
||||
def describe(x: Any, weights: Any | None = None) -> pd.DataFrame:
|
||||
"""一张表看完集中趋势、离散程度、偏度峰度、缺失情况。
|
||||
|
||||
`weights` 非空时给出加权均值与加权标准差(权重是"频数"或"重要性"时用)。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x:
|
||||
`pandas.DataFrame` / `pandas.Series` / list / tuple / `numpy.ndarray`。
|
||||
**只描述数值列**,非数值列自动跳过。
|
||||
weights:
|
||||
与 `x` 行数等长的**非负**权重,类型同 `x` 可接受的那几种;
|
||||
`None` 表示不给加权行。
|
||||
|
||||
Returns
|
||||
-------
|
||||
DataFrame,行是指标,列是被描述的变量。
|
||||
pandas.DataFrame
|
||||
行是指标,列是被描述的变量。指标依次为
|
||||
`count` / `mean` / `std` / `min` / `25%` / `50%` / `75%` / `max` /
|
||||
`median` / `missing` / `missing_ratio` / `skewness` / `kurtosis`;
|
||||
给了 `weights` 时再追加 `weighted_mean` / `weighted_std`。
|
||||
返回的是新表,原表不改。
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
`x` 或 `weights` 的类型不支持。
|
||||
ValueError
|
||||
`x` 里没有数值列;或 `weights` 无法转成数值、不是一维、长度与行数不符、
|
||||
含 NaN / inf、含负值、求和为 0。
|
||||
|
||||
Notes
|
||||
-----
|
||||
每个指标都**按列独立**计算;含缺失时 pandas 会自动跳过该单元格(`count` 因此
|
||||
可能小于行数,缺失量看 `missing` / `missing_ratio`)。
|
||||
加权标准差用总体口径(`ddof=0`)。含 NaN 的列加权后仍是 NaN ——
|
||||
加权前请先用 `impute_missing` 补好。
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> describe(df).loc["mean"] # doctest: +SKIP
|
||||
"""
|
||||
# TODO(L0)
|
||||
raise NotImplementedError
|
||||
allowed_types = (pd.DataFrame, pd.Series, list, tuple, np.ndarray)
|
||||
if not isinstance(x, allowed_types):
|
||||
raise TypeError(f"x 必须是{allowed_types},而你输入了{type(x)}")
|
||||
|
||||
if not isinstance(x, pd.DataFrame):
|
||||
x = pd.DataFrame(x)
|
||||
|
||||
numeric_cols = x.select_dtypes(include=["number"]).columns
|
||||
if len(numeric_cols) == 0:
|
||||
raise ValueError("x 里没有数值列,无法描述")
|
||||
|
||||
# df[Index] 在 pandas 存根里是 DataFrame | Series 重载,按运行时真值收窄(坑 8)
|
||||
df_num = cast(pd.DataFrame, x[numeric_cols])
|
||||
|
||||
# 这里**不要转置**。describe() 出来就是"行 = 指标、列 = 变量",正好让下面
|
||||
# `.loc[指标名] = <按变量索引的 Series>` 能按列名对齐;一旦转置,Series 的索引
|
||||
# 与表的列名不相交,pandas 会不报错地**把整行填成 NaN**(旧的静默 bug)。
|
||||
table = df_num.describe()
|
||||
table.loc["median"] = df_num.median()
|
||||
table.loc["missing"] = df_num.isna().sum()
|
||||
table.loc["missing_ratio"] = df_num.isna().mean()
|
||||
table.loc["skewness"] = df_num.skew()
|
||||
table.loc["kurtosis"] = df_num.kurt()
|
||||
|
||||
if weights is not None:
|
||||
allowed_weight_types = (list, tuple, np.ndarray, pd.Series)
|
||||
if not isinstance(weights, allowed_weight_types):
|
||||
raise TypeError(f"weights 必须是{allowed_weight_types},而你输入了{type(weights)}")
|
||||
|
||||
try:
|
||||
w_arr = np.asarray(weights, dtype=float)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"weights 无法转成数值数组,请检查是否含非数值字符:{e}") from None
|
||||
|
||||
if w_arr.ndim != 1:
|
||||
raise ValueError(f"weights 必须是一维数组,而你传入了 {w_arr.ndim} 维")
|
||||
if len(w_arr) != len(df_num):
|
||||
raise ValueError(f"权重 weights 的长度({len(w_arr)})必须与数据的行数({len(df_num)})一致")
|
||||
if not np.isfinite(w_arr).all():
|
||||
raise ValueError("weights 含 NaN 或 inf,无法加权")
|
||||
if (w_arr < 0).any():
|
||||
raise ValueError("weights 不能含负值")
|
||||
if w_arr.sum() == 0:
|
||||
raise ValueError("weights 之和为 0,无法加权")
|
||||
|
||||
weighted_mean = np.average(df_num, axis=0, weights=w_arr)
|
||||
weighted_std = np.sqrt(np.average((df_num - weighted_mean) ** 2, axis=0, weights=w_arr))
|
||||
|
||||
table.loc["weighted_mean"] = weighted_mean
|
||||
table.loc["weighted_std"] = weighted_std
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def zscore(x: Any, ddof: int = 0) -> Any:
|
||||
"""标准化到 z 分数,顺带回答了"这个值离均值几个标准差"。"""
|
||||
# TODO(L0)
|
||||
raise NotImplementedError
|
||||
"""标准化到 z 分数,顺带回答了"这个值离均值几个标准差"。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x:
|
||||
`pandas.DataFrame` / `pandas.Series` / list / tuple / `numpy.ndarray`。
|
||||
DataFrame **只取数值列**,非数值列不会出现在结果里。
|
||||
ddof:
|
||||
标准差的自由度修正,与 `numpy.std` 同名参数含义一致;`0` 为总体标准差。
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray / pandas.Series / pandas.DataFrame
|
||||
与 `x` 同类型;DataFrame 只保留数值列,列名与索引不变。
|
||||
常量列(`std == 0`)不产生 nan / inf,固定输出 0(决策 34)。
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
`x` 类型不支持。
|
||||
ValueError
|
||||
DataFrame 里一列数值列都没有。
|
||||
|
||||
Notes
|
||||
-----
|
||||
与 `data.transform.standardize` 是同一个公式;差别只在 DataFrame 里遇到
|
||||
非数值列时:这里**跳过**,`standardize` 会直接抛错。
|
||||
"""
|
||||
allowed_types = (pd.DataFrame, pd.Series, list, tuple, np.ndarray)
|
||||
if not isinstance(x, allowed_types):
|
||||
raise TypeError(f"仅接受{allowed_types},不接受{type(x)}")
|
||||
|
||||
is_df = isinstance(x, pd.DataFrame)
|
||||
is_series = isinstance(x, pd.Series)
|
||||
if is_df:
|
||||
numeric_cols = x.select_dtypes(include=[np.number]).columns
|
||||
if len(numeric_cols) == 0:
|
||||
raise ValueError("数据中没有数值列,无法计算 Z 分数")
|
||||
target_data = cast(pd.DataFrame, x[numeric_cols])
|
||||
original_cols = target_data.columns
|
||||
original_index = target_data.index
|
||||
else:
|
||||
target_data = x
|
||||
original_cols = None
|
||||
original_index = x.index if is_series else None
|
||||
|
||||
arr = np.asarray(target_data, dtype=float)
|
||||
|
||||
mean = np.mean(arr, axis=0)
|
||||
std = np.std(arr, axis=0, ddof=ddof)
|
||||
|
||||
std = np.where(std == 0, 1.0, std)
|
||||
|
||||
result = (arr - mean) / std
|
||||
if is_df:
|
||||
return pd.DataFrame(result, columns=original_cols, index=original_index)
|
||||
if is_series:
|
||||
return pd.Series(result, index=original_index)
|
||||
return result
|
||||
|
||||
|
||||
def coefficient_of_variation(x: Any) -> float:
|
||||
"""变异系数 `std / mean` —— 跨量纲比较"谁更波动"时的唯一正确做法。"""
|
||||
# TODO(L1)
|
||||
raise NotImplementedError
|
||||
"""变异系数 `std / mean` —— 跨量纲比较"谁更波动"时的唯一正确做法。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x:
|
||||
一维数据:`pandas.Series` / list / tuple / `numpy.ndarray`,
|
||||
或只有一列的 `pandas.DataFrame`。
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
`std / mean`,标准差按总体口径(`ddof=0`)。
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
`x` 类型不支持。
|
||||
ValueError
|
||||
数据为空、不是一维(或 DataFrame 列数不为 1)、或均值为 0。
|
||||
|
||||
Notes
|
||||
-----
|
||||
CV 只有在**均值为正**的比率尺度数据上才有意义:均值为负时本函数返回负值,
|
||||
那不是"波动更小",只是符号跟着均值走;均值接近 0 时 CV 会被放大到任意大。
|
||||
这两类数据请换用别的离散度指标。
|
||||
"""
|
||||
allowed_type = (list, tuple, np.ndarray, pd.Series)
|
||||
if isinstance(x, pd.DataFrame):
|
||||
if x.shape[1] != 1:
|
||||
raise ValueError(f"变异系数只适用于一维数据,而你传入了 {x.shape[1]} 列的 DataFrame")
|
||||
arr = np.asarray(x.iloc[:, 0], dtype=float)
|
||||
elif isinstance(x, allowed_type):
|
||||
arr = np.asarray(x, dtype=float)
|
||||
if arr.ndim != 1:
|
||||
raise ValueError(f"变异系数只适用于一维数据,而你传入了 {arr.ndim} 维数组")
|
||||
else:
|
||||
raise TypeError(f"x 必须是{allowed_type}或单列 pd.DataFrame,而你传入了{type(x)}")
|
||||
|
||||
if arr.size == 0:
|
||||
raise ValueError("数据为空,无法计算变异系数")
|
||||
|
||||
mean = np.mean(arr)
|
||||
std = np.std(arr, ddof=0)
|
||||
|
||||
if mean == 0:
|
||||
raise ValueError("均值为 0,无法计算变异系数(分母为 0)")
|
||||
|
||||
return float(std / mean)
|
||||
Reference in new issue
Block a user