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
+227 -54

No files matched your search

+22 -6
View File
@@ -125,17 +125,33 @@
- 测试计数:**64 passed → 82 passed**(新增 18 条,另两条 skip 转为实跑),skip 42 → 40。 - 测试计数:**64 passed → 82 passed**(新增 18 条,另两条 skip 转为实跑),skip 42 → 40。
- `data.transform`:修掉上一版引入的 **3 处真 bug**(**Codex 代修,用户授权"报错顺手修掉"**)。
症状是 7 条测试变红 + pyright 在 `transform.py:105` / `:133` 报 `reportArgumentType`。
**这不是编辑器误报** —— pyright CLI 与 `pytest` 都能复现,所以**一行 `# type: ignore` 都没加**:
- 输入白名单漏了 `pd.DataFrame`:`standardize(df)` / `log_transform(df)` 抛 `TypeError`,
函数体里的 `is_df` 分支全是**死代码**。白名单统一成模块级 `_NUMERIC_TYPES` 并移到 `np.asarray` 之前。
- `normalize` 的三个 method 分支**互相覆盖**:`minmax` / `vector` 算完的结果被末尾无条件的
`x / max` 冲掉,三种 method 实际都只走 max(`test_normalize_*` 5 条因此变红)。改成 `if / elif / else`。
- `one_hot_encode` 把 `get_dummies` 的结果按 `data.columns` 重包一层 `DataFrame`:
哑变量列名(`city_A`)与原列名(`city`)对不上 → 编码列被**整列丢掉**。改为直接返回 `get_dummies` 结果。
- `log_transform` 的 `offset` 守卫条件**写反**(`offset > 0` 就抛错,恰好把唯一合法用例拒掉),
改为校验定义域前提 `min(x) + offset > 0`;`offset=0` 对全正数据合法(见决策 35)。
- `data.transform` 四个函数的 docstring 按 `docs/02-architecture.md` §4.7 补齐
`Parameters` / `Returns` / `Raises`(Codex 代写,与 `io.py` / `clean.py` 同一套 numpydoc 格式),
并把常量列行为(决策 34)写进 `Notes`。
- `data.transform`:口径定稿后 ruff 剩的 4 条 RET505(`return` 后的 `elif`)一并清掉 ——
`All checks passed`;pyright **2 errors → 0 errors**;测试 **7 failed / 75 passed → 82 passed / 40 skipped**。
### Notes ### Notes
- `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进 - `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进
`clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、 `clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、
非数值列恒 `False`(决策 32)、入参类型校验补上(决策 33)。 非数值列恒 `False`(决策 32)、入参类型校验补上(决策 33)。
S 档卡片与 `examples/` 用法也已落地 —— `detect_outliers` 这条线**结清**。 S 档卡片与 `examples/` 用法也已落地 —— `detect_outliers` 这条线**结清**。
- `transform.py` 还有 **4 个口径待定**,所以四个函数的 docstring 里 `Parameters` / `Returns` / - `transform.py` 的 **4 个口径已全部收口**:① 三个函数共用 `_NUMERIC_TYPES` 白名单(含 `DataFrame`);
`Raises` 都还空着(§4.7 硬要求):① 三个函数的入参类型策略不一致 —— `normalize` 没有守卫, ② 全部接受 `DataFrame` 且保持"DataFrame 进 DataFrame 出";③ 显式 `offset` 按定义域校验
`normalize('abc')` 抛 numpy 的 `ValueError`、`normalize(None)` **静默返回 NaN**; (`min(x) + offset > 0`,见决策 35,**待用户确认**);④ 常量列行为写进 docstring 的 `Notes`;
② 要不要收 `DataFrame`(`normalize` 收但丢列名、`standardize` 直接 `TypeError`); 另外 `Parameters` / `Returns` / `Raises` 已按 §4.7 补齐。
③ 显式 `offset` 没校验,`log_transform(x, offset=-10.0)` 静默返回 NaN; - 新增易错点第 10 条:**把真报错当成"编辑器误报"** —— 红线先跑 CLI 与 `pytest` 再判断(`docs/dev/personal-pitfalls.md`)。
④ 常量列行为已被测试钉住(决策 34)但没写进 docstring。见 `STATUS.md`「下一步」第 1/2 条。
- 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `stats` / `viz`, - 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `stats` / `viz`,
`Result` 的用法届时并进去。见 `STATUS.md`「下一步」第 3 条。 `Result` 的用法届时并进去。见 `STATUS.md`「下一步」第 3 条。
- 已知缺口: - 已知缺口:
+28 -21
View File
@@ -10,7 +10,7 @@
**第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**, **第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**,
用户本人写 `src/`、Codex 负责审查 + `tests/`(决策 25)。 用户本人写 `src/`、Codex 负责审查 + `tests/`(决策 25)。
当前 5/12:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、`detect_outliers`。 当前 7/12:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、`detect_outliers`、`normalize`/`standardize`。
## 任务清单 ## 任务清单
@@ -62,7 +62,7 @@
- [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`), - [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`),
`~/.ssh/config` 加了一条域名别名,remote 里仍写域名 `~/.ssh/config` 加了一条域名别名,remote 里仍写域名
**E. L0 实现进度(4/12)** **E. L0 实现进度(7/12)**
- [x] `core/result.py` 的 `Result` - [x] `core/result.py` 的 `Result`
- `__repr__`:单行紧凑;`data` 只给类型与规模,**绝不**整数组打印 - `__repr__`:单行紧凑;`data` 只给类型与规模,**绝不**整数组打印
(十万级数组的 repr 实测仍 < 200 字符);`success` 恒在 (十万级数组的 repr 实测仍 < 200 字符);`success` 恒在
@@ -123,8 +123,18 @@
- Codex 清掉 5 条 ruff(RET503 / F541 / RET504 / RET505 / SIM108),并把 `feature_range` - Codex 清掉 5 条 ruff(RET503 / F541 / RET504 / RET505 / SIM108),并把 `feature_range`
的两条校验提到 `np.asarray` **之前**、修掉说反的报错文案(条件是"下界必须小于上界") 的两条校验提到 `np.asarray` **之前**、修掉说反的报错文案(条件是"下界必须小于上界")
- 测试:**新写 18 条**,其中两条是把原先挂着的 `skip` 转为实跑 —— 见 `tests/test_data.py` 的 transform 段 - 测试:**新写 18 条**,其中两条是把原先挂着的 `skip` 转为实跑 —— 见 `tests/test_data.py` 的 transform 段
- [ ] **待定:** `transform` 的 4 个口径问题(入参类型策略、要不要收 `DataFrame`、 - [x] **`transform.py` 的 4 个口径已收口**(2026-10-02,Codex 代修,用户授权"报错顺手修掉"):
显式 `offset` 的校验、常量列写进 docstring)—— 见「下一步」第 1/2 条 - 输入白名单统一成模块级 `_NUMERIC_TYPES`,**`DataFrame` 补进去**并移到 `np.asarray` 之前
(此前 `DataFrame` 不在白名单 → `standardize(df)` / `log_transform(df)` 直接 `TypeError`,
函数体里所有 `is_df` 分支都是**死代码**)
- 三个函数都保持"DataFrame 进 DataFrame 出"(列名 + 索引原样带回),Series 进 Series 出
- `normalize` 的三个 method 分支原来**互相覆盖**(`minmax` / `vector` 算完的结果被末尾
无条件的 `x / max` 冲掉,三种 method 实际都只走 max),改成 `if / elif / else`
- `one_hot_encode` 不再拿 `data.columns` 重包一层(哑变量列名对不上 → 编码列整列丢失)
- `log_transform` 的 `offset` 守卫**写反了**(`offset > 0` 就抛错,恰好拒掉唯一合法用例),
改成校验真正的定义域前提 `min(x) + offset > 0`(见决策 35)
- docstring 按 §4.7 补齐 `Parameters` / `Returns` / `Raises`(Codex 代写,与 `io.py` 同一套 numpydoc 格式)
- 口径定稿后 ruff 4 条 RET505 一并清掉 → `All checks passed`;pyright **2 errors → 0**
- [ ] 剩余 5 项:`describe` `correlation_matrix` `bootstrap_ci` `monte_carlo` `save_figure` - [ ] 剩余 5 项:`describe` `correlation_matrix` `bootstrap_ci` `monte_carlo` `save_figure`
### 验证结果 ### 验证结果
@@ -148,6 +158,9 @@
决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环 决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环
- `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz` - `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz`
- 40 条 skip 是等实现的算法(`train_test_split`、`describe`、`bootstrap_ci`、`monte_carlo` 等) - 40 条 skip 是等实现的算法(`train_test_split`、`describe`、`bootstrap_ci`、`monte_carlo` 等)
- **本轮回归(已修)**:`transform.py` 上一版有 3 处真 bug —— 修前 **7 failed / 75 passed**,
且 pyright 在同一处报 `reportArgumentType`。**不是静态误报**:pyright CLI 复现 + 测试真红。
结论:见到红线先跑 CLI 与 `pytest`,**不许直接挂 `# type: ignore`**(见个人易错点 10)
- 三个 `.ps1` 通过 PowerShell AST 语法解析 - 三个 `.ps1` 通过 PowerShell AST 语法解析
### ⚠️ 需要注意 / 遗留 ### ⚠️ 需要注意 / 遗留
@@ -208,22 +221,16 @@
| 32 | `detect_outliers` 对非数值列恒返回 `False`(既不跳过也不报错) | **用户本人在两条路里选的**。跳过会让 docstring 里"与原数据同形"变成谎话(形状对不上,没法按位置对回原表);报错则让一张带 `city` 列的真题表直接不可用。`False` 的读法是"这一列没有离群点",对文本列是最不坏的解释,也让整表能一次跑完 | | 32 | `detect_outliers` 对非数值列恒返回 `False`(既不跳过也不报错) | **用户本人在两条路里选的**。跳过会让 docstring 里"与原数据同形"变成谎话(形状对不上,没法按位置对回原表);报错则让一张带 `city` 列的真题表直接不可用。`False` 的读法是"这一列没有离群点",对文本列是最不坏的解释,也让整表能一次跑完 |
| 31 | Codex 可**直接改** `src/` 的范围:纯 bug(不改接口与语义)、格式、死代码 | 决策 25 说实现归用户,但"能跑"是底线:`winsorize(pd.Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩、测试变红,这种必须当场修。凡是要**选一个语义**的(文本列返回什么、mode 要不要就地改)一律留给用户本人,Codex 只把选项和影响列清楚 | | 31 | Codex 可**直接改** `src/` 的范围:纯 bug(不改接口与语义)、格式、死代码 | 决策 25 说实现归用户,但"能跑"是底线:`winsorize(pd.Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩、测试变红,这种必须当场修。凡是要**选一个语义**的(文本列返回什么、mode 要不要就地改)一律留给用户本人,Codex 只把选项和影响列清楚 |
| 27 | base 里的 zstandard 残骸目录**改名禁用**,不删除、不重装 | 删掉不可逆;`pip install zstandard` 会给 base 塞一个 conda 记录里根本没有的包,下次 `conda update --all` 又要重新判断。改名后 `import` 变回正常 `ModuleNotFoundError`,pandas 自动跳过 | | 27 | base 里的 zstandard 残骸目录**改名禁用**,不删除、不重装 | 删掉不可逆;`pip install zstandard` 会给 base 塞一个 conda 记录里根本没有的包,下次 `conda update --all` 又要重新判断。改名后 `import` 变回正常 `ModuleNotFoundError`,pandas 自动跳过 |
| 35 | `log_transform` 的显式 `offset` 按**定义域前提**校验:`min(x) + offset > 0` 才放行 |
原码写的是 `if offset > 0: raise`,把唯一合法的用例(正 offset 正好抬定义域)全拒了,报错文案却是"offset 必须大于 0",自相矛盾。改成校验真正的前提:`log` 要求自变量取正。`offset=0` 对全正数据合法(`offset=None` 的自动分支本来就取 0)。**Codex 代选、待用户确认** —— 若原意是"只收正 offset",改回即可 |
## 下一步 ## 下一步
1. **`transform.py` 还剩 4 个口径要你拍板**(都要落到 docstring 的 `Raises` / `Notes` 里): 1. **`stats.describe` 是 L0 里下一个该写的**(A 档,`stats/descriptive.py`)——
- **入参类型策略三个函数不一致**:`standardize` / `log_transform` 有守卫,只收 `examples/00_quickstart.py` 与 `examples/01_clean_and_describe.py` 末尾的 `TODO(L0)` 都在等它。
`int/float/list/tuple/ndarray/Series`;`normalize` 没有,全靠 `np.asarray` 自己崩 —— 写法照 `data/clean.py` 的 `clean_missing`:先想"替代了哪段手写代码" → 先写一条会红的测试
`normalize('abc')` 抛 numpy 的 `ValueError`(不是 §4.7 要的 `TypeError`), → 再实现 → 补 `examples/` 一行(`docs/03-roadmap.md` §3.2)。
`normalize(None)` 更糟:**静默返回 NaN**。建议照 `standardize` 的元组抄一份守卫,三处统一。 2. 之后按 L0 剩余顺序推进:`correlation_matrix`(A)→ `bootstrap_ci`(**S 档,要写函数卡片**)
- **要不要收 `DataFrame`**:`normalize(df)` 现在能跑,但返回 ndarray、**列名丢了**; → `monte_carlo`(**S 档**)→ `save_figure`(A)。
`standardize(df)` 直接 `TypeError`。`load_table` 出来的就是 DataFrame, 3. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
建议三个函数都收 DataFrame、按列处理、并保持 DataFrame 返回(列名留着才好回填原表)。 4. **待你确认(决策 35)**:`log_transform` 的显式 `offset` 现在按"定义域前提"校验
- **显式 `offset` 没校验**:`log_transform(x, offset=-10.0)` 静默返回 NaN(`np.log` 负数 + RuntimeWarning)。 (`min(x) + offset > 0`,否则 `ValueError`)。若你原意是"只收正 offset",说一声我改回去。
要么校验(`x + offset <= 0` 就抛 `ValueError`),要么在 docstring 里写清"由调用方保证"。
- **常量列行为写进 docstring**:行为已被测试钉住(决策 34),但 docstring 还是空白。
2. 四个函数的 docstring 都缺 `Parameters` / `Returns` / `Raises`(§4.7 硬要求)。
上面 4 点定了之后一次补齐 —— 现在补也会因为口径未定而要返工。
3. `stats.describe` + `stats` 其余函数:`examples/00_quickstart.py` 与
`examples/01_clean_and_describe.py` 末尾的 `TODO(L0)` 都在等它。
4. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
+17
View File
@@ -142,3 +142,20 @@
条数没变就说明实现没被验证过。另一个信号是 `pytest --collect-only` 的收集数 —— 条数没变就说明实现没被验证过。另一个信号是 `pytest --collect-only` 的收集数 ——
新写的测试没进收集列表,就是名字或位置不对。 新写的测试没进收集列表,就是名字或位置不对。
- 首次:2026-10-02|复发:0 - 首次:2026-10-02|复发:0
### 10. 把真报错当成"编辑器误报"
- 现象:`transform.py` 改完后编辑器(Pylance)在 `pd.DataFrame(result, columns=x.columns,
index=x.index)` 两行画红线,第一反应是"静态检查又误报了,挂个 `# type: ignore` 就完了"。
实际跑一遍:**pyright CLI 报同样的 2 个错**(Pylance 就是 pyright 的内核),
而且 `pytest` **7 条红**(`normalize` 的三种 method 全走错、`one_hot_encode` 把编码列整列丢掉)。
红线只是**症状**,病在代码里。
- 为什么会错:编辑器把错误显示在**它看得见的那一行**,而那一行的类型确实和来源不符;
但"类型不符"既可能是检查器保守(误报),也可能是**上游真的算错了**。
不验证就下"误报"结论,等于用一个假设替代一次检查。
- 以后怎么防:见到红线先做两件事,再谈 `# type: ignore` ——
① 在**终端**跑同引擎的 CLI(`python -m pyright`),排除编辑器缓存/配置差异;
② 跑 `pytest`,看对应的行为断言红不红。两条都过、确认只是检查器保守推断,
才在**最小范围**加 `# type: ignore[具体错误码]`;裸 `# type: ignore` 会连带关掉后面所有行
(更好的写法见 `docs/04-environment.md` 坑 8 的 `cast`)。
- 首次:2026-10-02|复发:0
+160 -27
View File
@@ -15,19 +15,41 @@ import pandas as pd
__all__ = ["log_transform", "normalize", "one_hot_encode", "standardize"] __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: def normalize(x: Any, method: str = "minmax", feature_range: tuple[float, float] = (0.0, 1.0)) -> Any:
"""无量纲化到给定区间。 """无量纲化到给定区间。
Parameters Parameters
---------- ----------
x:
数值型标量 / list / tuple / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`;
多维输入一律**按列**(`axis=0`)处理。
method: method:
`"minmax"`:`(x - min) / (max - min)`,线性压缩到 `feature_range`; `"minmax"`:`(x - min) / (max - min)`,线性压缩到 `feature_range`;
`"vector"`:`x / ||x||`,保持"份额"含义(熵权法前处理常用); `"vector"`:`x / ||x||`,保持"份额"含义(熵权法前处理常用);
`"max"`:`x / max(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 Notes
----- -----
常量列(`max == min`)不产生 nan / inf,固定压到 `feature_range` 的下界。
minmax 对离群值极度敏感:一个 100 倍大的异常值会把其余所有点压成一条线。 minmax 对离群值极度敏感:一个 100 倍大的异常值会把其余所有点压成一条线。
先跑 `detect_outliers` 再决定。 先跑 `detect_outliers` 再决定。
@@ -36,43 +58,83 @@ def normalize(x: Any, method: str = "minmax", feature_range: tuple[float, float]
>>> normalize([1, 2, 3]).tolist() >>> normalize([1, 2, 3]).tolist()
[0.0, 0.5, 1.0] [0.0, 0.5, 1.0]
""" """
supported_method = ['minmax','vector','max'] supported_method = ["minmax", "vector", "max"]
if method not in supported_method: if method not in supported_method:
raise ValueError(f"不支持的方法:{method},仅支持{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: if not isinstance(feature_range, tuple) or len(feature_range) != 2:
raise TypeError(f"feature_range必须是形如(low, high)的二元组,你传入了{feature_range}") raise TypeError(f"feature_range必须是形如(low, high)的二元组,你传入了{feature_range}")
if feature_range[0] >= feature_range[1]: if feature_range[0] >= feature_range[1]:
raise ValueError(f"feature_range的下界必须小于上界,你传入了{feature_range}") raise ValueError(f"feature_range的下界必须小于上界,你传入了{feature_range}")
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) arr = np.asarray(x, dtype=float)
if method == 'minmax': if method == "minmax":
x_min = np.min(arr, axis=0) x_min = np.min(arr, axis=0)
x_max = np.max(arr, axis=0) x_max = np.max(arr, axis=0)
x_range = np.where(x_max - x_min == 0, 1.0, x_max - x_min)
x_range = x_max - x_min
x_range = np.where(x_range == 0, 1.0, x_range)
scaled = (arr - x_min) / x_range scaled = (arr - x_min) / x_range
return scaled * (feature_range[1] - feature_range[0]) + feature_range[0] result = scaled * (feature_range[1] - feature_range[0]) + feature_range[0]
elif method == "vector":
if method == 'vector':
norm = np.linalg.norm(arr, axis=0) norm = np.linalg.norm(arr, axis=0)
norm = np.where(norm == 0, 1.0, norm) norm = np.where(norm == 0, 1.0, norm)
return arr / norm result = arr / norm
else:
# 上面已校验 method 合法,所以这里不必再写 elif,兜住仅剩的 'max' # 上面已校验 method 合法,所以这里不必再写 elif,兜住仅剩的 "max"
x_max = np.max(arr, axis=0) x_max = np.max(arr, axis=0)
x_max = np.where(x_max == 0, 1.0, x_max) x_max = np.where(x_max == 0, 1.0, x_max)
return arr / x_max result = 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: def standardize(x: Any, ddof: int = 0) -> Any:
"""标准化 `(x - mean) / std`,输出均值 0、标准差 1。""" """标准化 `(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)}") 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) 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) 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: 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)): if not isinstance(x, _NUMERIC_TYPES):
raise TypeError(f"输入必须为数值型、列表、numpy array 或 pandas Series,你传入了{type(x)}") 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) arr = np.asarray(x, dtype=float)
if offset is None: if offset is None:
min_val = np.min(arr) min_val = np.min(arr)
offset = 1.0 - min_val if min_val <= 0 else 0.0 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: 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): if not isinstance(data, pd.DataFrame):
raise TypeError(f"data必须是 pandas DataFrame, 你传入了{type(data)}") 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}") raise ValueError(f"指定的列不存在于数据中:{missing_cols}")
cols_to_encode = columns cols_to_encode = columns
else: else:
# 'str' / 'string' 必须显式写上:pandas 3 起字符串列的 dtype 是 'str', # "str" / "string" 必须显式写上:pandas 3 起字符串列的 dtype 是 "str",
# 只写 'object' 目前还能选中,但会发 Pandas4Warning,且官方声明未来移除该行为 # 只写 "object" 目前还能选中,但会发 Pandas4Warning,且官方声明未来移除该行为
# —— 到那天这里会**静默**一列都不编码,比报错难查得多。 # —— 到那天这里会**静默**一列都不编码,比报错难查得多。
object_like = ['object', 'category', 'str', 'string'] cols_to_encode = data.select_dtypes(include=["object", "category", "str", "string"]).columns.tolist()
cols_to_encode = data.select_dtypes(include=object_like).columns.tolist()
return pd.get_dummies(data, columns=cols_to_encode, drop_first=drop_first) return pd.get_dummies(data, columns=cols_to_encode, drop_first=drop_first)