fix(data): 修掉 transform.py 的 3 处真 bug,并收口 4 个口径

不是编辑器误报:pyright CLI 与 pytest 都能复现(修前 7 failed / 75 passed),
所以一行 # type: ignore 都没加。

- 输入白名单漏了 pd.DataFrame -> standardize/log_transform 直接 TypeError,
  函数体里的 is_df 分支全是死代码;统一成模块级 _NUMERIC_TYPES 并前置到 np.asarray 之前
- normalize 三个 method 分支互相覆盖(minmax/vector 的结果被末尾无条件的 x/max 冲掉,
  实际三种 method 都只走 max),改成 if/elif/else
- one_hot_encode 按 data.columns 重包一层 DataFrame -> 哑变量列名对不上,编码列整列丢失,
  改为直接返回 get_dummies 的结果
- log_transform 的 offset 守卫写反(offset > 0 就抛错,恰好拒掉唯一合法用例),
  改为校验定义域前提 min(x) + offset > 0;offset=0 对全正数据合法
- 四个函数 docstring 按 docs/02-architecture.md 4.7 补齐 Parameters/Returns/Raises
- ruff 4 条 RET505 清掉;pyright 2 errors -> 0;测试 82 passed / 40 skipped

docs: STATUS.md L0 进度 7/12 + 决策 35;CHANGELOG 记录本轮回归与修复;
      personal-pitfalls 追加第 10 条(把真报错当成编辑器误报)
This commit is contained in:
guzhujushi committed 2026-10-02 21:48:27 +08:00
1 parent 6eab0babde
commit 4552aee41c
4 files changed
+229 -56

No files matched your search

+162 -29
View File
@@ -15,19 +15,41 @@ import pandas as pd
__all__ = ["log_transform", "normalize", "one_hot_encode", "standardize"]
# 数值输入白名单。DataFrame 必须写进来,否则下面所有 is_df 分支都是死代码。
_NUMERIC_TYPES = (int, float, list, tuple, np.ndarray, pd.Series, pd.DataFrame)
def normalize(x: Any, method: str = "minmax", feature_range: tuple[float, float] = (0.0, 1.0)) -> Any:
"""无量纲化到给定区间。
Parameters
----------
x:
数值型标量 / list / tuple / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`;
多维输入一律**按列**(`axis=0`)处理。
method:
`"minmax"`:`(x - min) / (max - min)`,线性压缩到 `feature_range`;
`"vector"`:`x / ||x||`,保持"份额"含义(熵权法前处理常用);
`"max"`:`x / max(x)`。
feature_range:
仅 `method="minmax"` 时生效的目标区间,形如 `(low, high)`。
Returns
-------
numpy.ndarray / pandas.Series / pandas.DataFrame
与 `x` 同类型:DataFrame 进 DataFrame 出(列名、索引保持不变),
Series 进 Series 出,其余返回 `numpy.ndarray`。
Raises
------
ValueError
`method` 不在支持列表内,或 `feature_range[0] >= feature_range[1]`。
TypeError
`x` 类型不支持,或 `feature_range` 不是二元组。
Notes
-----
常量列(`max == min`)不产生 nan / inf,固定压到 `feature_range` 的下界。
minmax 对离群值极度敏感:一个 100 倍大的异常值会把其余所有点压成一条线。
先跑 `detect_outliers` 再决定。
@@ -36,43 +58,83 @@ def normalize(x: Any, method: str = "minmax", feature_range: tuple[float, float]
>>> normalize([1, 2, 3]).tolist()
[0.0, 0.5, 1.0]
"""
supported_method = ['minmax','vector','max']
supported_method = ["minmax", "vector", "max"]
if method not in supported_method:
raise ValueError(f"不支持的方法:{method},仅支持{supported_method}")
if method == 'minmax':
if method == "minmax":
if not isinstance(feature_range, tuple) or len(feature_range) != 2:
raise TypeError(f"feature_range必须是形如(low, high)的二元组,你传入了{feature_range}")
if feature_range[0] >= feature_range[1]:
raise ValueError(f"feature_range的下界必须小于上界,你传入了{feature_range}")
arr = np.asarray(x,dtype=float)
if not isinstance(x, _NUMERIC_TYPES):
raise TypeError(f"输入必须为数值型、列表、numpy array、pandas Series 或 DataFrame,你传入了{type(x)}")
if method == 'minmax':
is_df = isinstance(x, pd.DataFrame)
is_series = isinstance(x, pd.Series)
original_cols: Any = x.columns if is_df else None
original_index: Any = x.index if (is_df or is_series) else None
arr = np.asarray(x, dtype=float)
if method == "minmax":
x_min = np.min(arr, axis=0)
x_max = np.max(arr, axis=0)
x_range = x_max - x_min
x_range = np.where(x_range == 0, 1.0, x_range)
x_range = np.where(x_max - x_min == 0, 1.0, x_max - x_min)
scaled = (arr - x_min) / x_range
return scaled * (feature_range[1] - feature_range[0]) + feature_range[0]
if method == 'vector':
result = scaled * (feature_range[1] - feature_range[0]) + feature_range[0]
elif method == "vector":
norm = np.linalg.norm(arr, axis=0)
norm = np.where(norm == 0, 1.0, norm)
return arr / norm
result = arr / norm
else:
# 上面已校验 method 合法,所以这里不必再写 elif,兜住仅剩的 "max"
x_max = np.max(arr, axis=0)
x_max = np.where(x_max == 0, 1.0, x_max)
result = arr / x_max
# 上面已校验 method 合法,所以这里不必再写 elif,兜住仅剩的 'max'
x_max = np.max(arr, axis=0)
x_max = np.where(x_max == 0, 1.0, x_max)
return arr / x_max
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 standardize(x: Any, ddof: int = 0) -> Any:
"""标准化 `(x - mean) / std`,输出均值 0、标准差 1。"""
if not isinstance(x, (int, float, list, tuple, np.ndarray, pd.Series)):
raise TypeError(f"输入必须为数值型、列表、numpy array 或 pandas Series,你传入了{type(x)}")
"""标准化 `(x - mean) / std`,输出均值 0、标准差 1。
Parameters
----------
x:
数值型标量 / list / tuple / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`;
多维输入一律**按列**(`axis=0`)处理。
ddof:
标准差的自由度修正,与 `numpy.std` 同名参数含义一致;`0` 为总体标准差。
Returns
-------
numpy.ndarray / pandas.Series / pandas.DataFrame
与 `x` 同类型,DataFrame 的列名与索引保持不变。
Raises
------
TypeError
`x` 类型不支持。
Notes
-----
常量列(`std == 0`)不产生 nan / inf,固定输出 0,与 `sklearn` 的
`StandardScaler` 对零方差列的处理一致。
"""
if not isinstance(x, _NUMERIC_TYPES):
raise TypeError(f"输入必须为数值型、列表、numpy array、pandas Series 或 DataFrame,你传入了{type(x)}")
is_df = isinstance(x, pd.DataFrame)
is_series = isinstance(x, pd.Series)
original_cols: Any = x.columns if is_df else None
original_index: Any = x.index if (is_df or is_series) else None
arr = np.asarray(x, dtype=float)
@@ -82,27 +144,100 @@ def standardize(x: Any, ddof: int = 0) -> Any:
std = np.where(std == 0, 1.0, std)
return (arr - mean) / 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 log_transform(x: Any, offset: float | None = None) -> Any:
"""对数变换,用于压缩右偏分布。
`offset=None` 时自动取 `1 - min(x)`(含 0 或负值时)保证定义域。
Parameters
----------
x:
数值型标量 / list / tuple / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`。
offset:
平移量,算的是 `log(x + offset)`。
`None` 时自动取 `1 - min(x)`(当 `min(x) <= 0`),否则取 `0`,以保证定义域。
Returns
-------
numpy.ndarray / pandas.Series / pandas.DataFrame
与 `x` 同类型,DataFrame 的列名与索引保持不变。
Raises
------
TypeError
`x` 类型不支持。
ValueError
显式传入的 `offset` 会让 `x + offset <= 0`,即破坏 `log` 的定义域。
Examples
--------
>>> log_transform([1.0, 2.718281828459045]).round(3).tolist()
[0.0, 1.0]
"""
if not isinstance(x, (int, float, list, tuple, np.ndarray, pd.Series)):
raise TypeError(f"输入必须为数值型、列表、numpy array 或 pandas Series,你传入了{type(x)}")
if not isinstance(x, _NUMERIC_TYPES):
raise TypeError(f"输入必须为数值型、列表、numpy array、pandas Series 或 DataFrame,你传入了{type(x)}")
is_df = isinstance(x, pd.DataFrame)
is_series = isinstance(x, pd.Series)
original_cols: Any = x.columns if is_df else None
original_index: Any = x.index if (is_df or is_series) else None
arr = np.asarray(x, dtype=float)
if offset is None:
min_val = np.min(arr)
offset = 1.0 - min_val if min_val <= 0 else 0.0
return np.log(arr + offset)
elif np.min(arr) + offset <= 0:
raise ValueError(
f"offset={offset} 会破坏 log 定义域: x + offset 的最小值为 {np.min(arr) + offset},必须大于 0"
)
result = np.log(arr + offset)
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 one_hot_encode(data: Any, columns: list[str] | None = None, drop_first: bool = False) -> Any:
"""类别变量独热编码。"""
"""类别变量独热编码。
Parameters
----------
data:
要编码的表,必须是 `pandas.DataFrame`。
columns:
要编码的列名列表;`None` 表示自动识别所有文本 / 类别列
(`object` / `category` / `str` / `string` 四种 dtype)。
drop_first:
是否丢掉每个类别变量的第一个水平,避免哑变量陷阱(等价 `pandas.get_dummies` 同名参数)。
Returns
-------
pandas.DataFrame
编码后的新表,**原表不改**;新哑变量列形如 `city_A`。
Raises
------
TypeError
`data` 不是 `pandas.DataFrame`。
ValueError
`columns` 里出现了原表中不存在的列名。
Notes
-----
这里**不能**把结果再按 `data.columns` 重新包一层 `DataFrame`:
哑变量列名(`city_A`)和原列名(`city`)对不上,那样会把编码结果整列丢掉。
"""
if not isinstance(data, pd.DataFrame):
raise TypeError(f"data必须是 pandas DataFrame, 你传入了{type(data)}")
@@ -112,11 +247,9 @@ def one_hot_encode(data: Any, columns: list[str] | None = None, drop_first: bool
raise ValueError(f"指定的列不存在于数据中:{missing_cols}")
cols_to_encode = columns
else:
# 'str' / 'string' 必须显式写上:pandas 3 起字符串列的 dtype 是 'str',
# 只写 'object' 目前还能选中,但会发 Pandas4Warning,且官方声明未来移除该行为
# "str" / "string" 必须显式写上:pandas 3 起字符串列的 dtype 是 "str",
# 只写 "object" 目前还能选中,但会发 Pandas4Warning,且官方声明未来移除该行为
# —— 到那天这里会**静默**一列都不编码,比报错难查得多。
object_like = ['object', 'category', 'str', 'string']
cols_to_encode = data.select_dtypes(include=object_like).columns.tolist()
cols_to_encode = data.select_dtypes(include=["object", "category", "str", "string"]).columns.tolist()
return pd.get_dummies(data, columns=cols_to_encode, drop_first=drop_first)