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:
guzhujushi committed 2026-10-03 13:17:58 +08:00
1 parent 4552aee41c
commit e9624cb3a7
6 files changed
+468 -34

No files matched your search

+194 -11
View File
@@ -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)