# 可复用的 ELM 流水线 **Repository Path**: az13js/elmkit ## Basic Information - **Project Name**: 可复用的 ELM 流水线 - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-29 - **Last Updated**: 2026-09-30 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # elmkit —— 可复用的 ELM 流水线 **降维 → 初始化 → 尺度归一 → 闭式岭回归读出**,一行拿到调好的配置。 默认值来自一系列 ELM 实验(原始实验目录 `elminit/`)。**本包不依赖那个目录**: 下面出现的 MNIST 数字是那批实验的**历史记录**,MNIST 数据不随包分发。 ## 安装与依赖 ```bash pip install . # 或 pip install -e .(可编辑安装,已实测可用) ``` - **运行期依赖只有 numpy(>=1.22)**,声明在 `pyproject.toml` 里; 没有 scipy / sklearn / torch / pandas 依赖。 - demo 与 test 的数据由 `elmkit.datasets` **现算**(八瓣鞍面回归 + 象限分类), **不读外部数据集、不需要网络**。 - 只有在「复现历史 MNIST 数字」时才需要自备数据(见 §6,用环境变量指路径)。 ```python from elmkit import ELM model = ELM(n_components=32, n_hidden=2000).fit(X_train, y_train) model.score(X_test, y_test) # MNIST 历史记录: 0.9716 model.predict(X_test) # 分类 / 回归自动判断;也可显式指定。注意 task 是 fit() 的参数,不是构造器的 model = ELM(n_components=32, n_hidden=2000).fit(X_train, y_train, task='regression') ``` --- ## 目录 - [1. 默认值是怎么来的](#1-默认值是怎么来的不是猜的) - [2. 三条容易踩的坑](#2-三条容易踩的坑都有实验支撑) - [3. `bias_ratio` 的坑:最优点依任务而变](#3-bias_ratio-的坑最优点依任务而变) - [3b. `bias_ratio` 不能跨任务搬运(exp15 实测)](#3b-bias_ratio-不能跨任务搬运exp15-实测本包最危险的参数) - [4. 关于「神经元应当互不相似」](#4-关于神经元应当互不相似用户假设) - [5. API](#5-api) - [6. 测试与演示](#6-测试与演示) - [7. 开发过程中修掉的 6 个 bug](#7-开发过程中修掉的-6-个-bug留档) - [8. 剪枝:有,但只省参数、不提精度](#8-剪枝有但只省参数不提精度) - [9. PCA 的时间 / 空间复杂度(实测)](#9-pca-的时间--空间复杂度实测) --- --- ## 1. 默认值是怎么来的(不是猜的) | 配置 | acc | 参数量 | 出处 | |---|---|---|---| | 784 维, M=1000(目录原基线) | 0.9411 | 795,000 | `elm/` | | PCA 32 维, M=2000(**本包默认**) | **0.9716** | 86,000 | §11 + 本文 §3 | | PCA 32 维, M=3000 | 0.9756 | 129,000 | §11 | | 784 维, M=3000 | 0.9645 | 2,385,000 | exp2 | **默认配置用 86k 参数超过原基线 3.05pp,参数量只有 10.8%。** > ⚠️ 口径提醒:`__init__.py` 里记的 784 维基线是 **0.9449**,与上表的 0.9411 不一致 —— > 它们来自不同脚本/种子口径,都是历史记录,且**没有随包分发的数据可以复算**。 > 本包内可复算的只有 §6 的可选对齐(PCA32 的三个点)。 三个默认值的依据: | 参数 | 默认 | 依据 | |---|---|---| | `n_components` | 32 | §11.2:固定宽度下 d=32 是内部最优点;**白化更差**(截断 6/6 全胜) | | `n_hidden` | 2000 | §11.3:等精度下最省参数的点(86k 达 0.969) | | `bias_ratio` | 5.0 | 见 §3 —— 依任务而变,MNIST 上是 5.0(+0.22pp, 5/5 种子) | | `scheme` | `'u11'` | exp5:8 方案 × 5 种子,差异全在噪声内;d=32 上初始化增益仅 +0.06pp | --- --- ## 2. 三条容易踩的坑(都有实验支撑) ### 坑 1:别靠换初始化提精度 elminit exp5(MNIST,8 方案 × 5 种子):跨种子 std 仅 **0.17pp**, 最好的 `orth` 也只高 +0.23pp 且在 exp6/exp11 复核时**不可复现**。 在 d=32 上初始化增益更是只有 **+0.06pp**。 ⇒ 真正有效的是**降维 + 加宽**,不是挑初始化。 ### 坑 2:别用白化 PCA 截断 vs 白化(除以 √λ),6 组配置: | d | 截断 M=1000 | 白化 M=1000 | 截断 M=3000 | 白化 M=3000 | |---|---|---|---|---| | 16 | **0.9483** | 0.9440 | **0.9622** | 0.9590 | | 32 | **0.9625** | 0.9597 | **0.9725** | 0.9687 | | 64 | **0.9591** | 0.9452 | **0.9736** | 0.9678 | 增益来自**丢弃小方差方向(去噪)**,不是各向同性化。 ### 坑 3:别用绝对 lam `lam = alpha * trace(G)/d`(尺度自适应)不只是数值稳定补丁,它同时是 **尺度不变性的来源**:ReLU 正齐次 ⇒ `H_s = s·H₁`、`lam_s = s²lam₁` ⇒ `sβ_s` 与 s 无关 ⇒ `argmax(H_s β_s)` **逐样本完全相同**(实测 300 倍尺度范围全同率 1.000000)。 推论:**「调整体尺度」型初始化在 ELM 里是严格零操作**(`I4` 臂与 `I0` 逐位相同)。 用绝对 `C_REG`(如 0.01)会重新暴露尺度敏感性并导致数值病态 (`elm/exp4` 的 bug 就是这个:Gram 尺度 2.06e8 vs C=0.01,差 10 个数量级)。 --- --- ## 3. `bias_ratio` 的坑:最优点依任务而变 `scale_fit` 做**逐节点**把 `(w_j,b_j)` 缩放到 `std(z_j)=1`。 注意这**不是**规范自由度(整体缩放才是),所以它真的改变特征几何。 归一之后,偏置还有第二个自由度:相对尺度 `c`。 **2 维任务上(§10.3)**: | c | 0.0 | 0.2 | **0.3** | 0.5 | 1.0 | 2.0 | |---|---|---|---|---|---|---| | RMSE | 0.0913 | 0.0143 | **0.0128** | 0.0131 | 0.0168 | 0.0253 | **但把 0.3 搬到 MNIST 上是错的**(PCA32, M=2000,3 种子): | c | None | 0.3 | 1.0 | 2.0 | 3.0 | **5.0** | 10 | 20 | 50 | |---|---|---|---|---|---|---|---|---|---| | acc | 0.9688 | 0.9674 | 0.9690 | 0.9694 | 0.9706 | **0.9712** | 0.9661 | 0.9521 | 0.9174 | MNIST 的最优在 **c≈5**,且 5 种子配对验证 `5.0` vs `None`: **0.9715 vs 0.9693,Δ=+0.22pp,5/5 同向**。c 超过 ~10 后急跌 ⇒ 清晰的内部最优。 > **教训:最优点依任务而变,不可跨任务搬运。换个数据集请重新扫 `bias_ratio`。** > `bias_ratio=1.0` 等价于「保留原始 b/w 比例」(实测与 `None` 一致)。 --- --- ## 3b. `bias_ratio` 不能跨任务搬运(exp15 实测,**本包最危险的参数**) 同一套库、同一个参数,在两个任务上的最优值差 **17 倍**,而用错代价极大。 **八瓣鞍面(2 维,M=2000,3 种子)**: | bias_ratio | None | 0.1 | 0.2 | **0.3** | 0.4 | 0.5 | 1.0 | 2.0 | 5.0 | |---|---|---|---|---|---|---|---|---|---| | RMSE | 0.0048 | 0.0034 | 0.0018 | **0.0016** | 0.0019 | 0.0023 | 0.0048 | 0.0158 | **0.0702** | **MNIST(784 维)**:最优 c ≈ **5.0**(见 §3)。 ⇒ **用 MNIST 的默认 5.0 去做 2 维鞍面,结果差 44 倍**(0.0016 → 0.0702,M=2000), 整个 ELM 退化成「预测均值」(目标 std = 0.103,RMSE 0.070 意味着几乎没学到东西)。 倍数随宽度而变:`demo.py` / `test_elmkit.py` 用 M=1000 跑同一个比较,量到的是 34.9×。 **规律:输入维度越低,最优 `c` 越小**(2 维 ≈0.3,784 维 ≈5.0)。 机制:`c` 决定 ReLU 门限落在输入分布何处;维度高时 `‖x‖` 大、预激活尺度大, 需要更大的 `b` 才能把门限推进分布内部。 ### 所以提供了 `tune_bias_ratio` ```python from elmkit import tune_bias_ratio r = tune_bias_ratio(X_train, y_train, n_hidden=1000, verbose=True) # bias_ratio=None score=0.006618 # bias_ratio=0.3 score=0.002508 ← 选中 # bias_ratio=5.0 score=0.081437 model = ELM(bias_ratio=r['best']).fit(X_train, y_train) ``` **换任务的第一件事就是跑它。** 它自动从训练集尾部切 20% 做验证, 不需要额外准备验证集;实测在 8000 样本上就能选出与全量一致的最优值。 --- ## 4. 关于「神经元应当互不相似」(用户假设) 这条假设我单独查了 5 个实验,结论分三层: | 命题 | 判决 | 证据 | |---|---|---| | 神经元高度相似**有害** | ✅ **强证据** | 把 1000 个方向压到 k 个独立方向,k: 1000→2 使 RMSE 0.0027→0.1024(**×37.9**),Spearman ρ = **−1.000** | | 能用**正交化**去相关 | ✅ 可以 | `orthogonalize()`,行间 \|cos\| 降到 2.3e-17 | | 正交化能**提精度** | ❌ **不能** | 行范数对齐后,完全正交 0.016731 vs 非正交 0.016094(**正交反而差 3.8%**) | | 2 维输入上能去相关 | ❌ 做不到 | 所有臂 \|cos\| p95 锁在 0.91–0.92、max 恒 =1.0;白化反解残差 0.9987 | **所以 `decorrelate` 默认关闭。** 打开它只让神经元更不相似,**不提升精度**: ``` decorrelate=False acc=0.9623 |cos|p95=0.5481 decorrelate=True acc=0.9607 |cos|p95=0.5469 ← 略降 ``` 真正有效的是**尺度**,不是相似度。 --- --- ## 5. API ### `ELM`(主入口) ```python ELM(n_components=32, # PCA 维度;0 = 不降维 n_hidden=2000, # 隐藏层宽度 M scheme='u11', # 初始化方案,见 inits.SCHEMES alpha=1e-3, # 岭回归正则(尺度自适应) bias_ratio=5.0, # 偏置相对尺度;None = 只归一不调偏置 scale_normalize=True, # 逐节点缩放到 std(z)=1 decorrelate=False, # 正交化(实测无精度增益) whiten=False, # PCA 白化(实测更差) seed=42, raw_seed=False, # True = 用 default_rng(seed),复现 exp13/14 历史数字 blk=6000, # 分块行数(内存控制) diagnose=False) # 是否算几何诊断 ``` 方法:`fit(X, y, task=None, n_classes=None)` / `predict` / `predict_proba` / `decision` / `score`(分类→accuracy,回归→R²) / `summary()`。 属性:`n_params_`(存储参数)、`n_readout_params_`、`diag_`。 ### 子模块 | 模块 | 内容 | |---|---| | `elmkit.PCA` | PCA 截断(`whiten=False` 默认,`cum_var_ratio_` 诊断) | | `elmkit.inits` | `make_weights`(8 方案)、`scale_fit`、`orthogonalize`、`decorrelate`、`col_cos_stats`、`eff_rank` | | `elmkit.readout` | `RidgeReadout`、`fit_ridge`(尺度自适应 lam + Cholesky + 分块) | | `elmkit.prune` | `prune_nodes`(`magnitude`/`energy`/`random`)—— 见 §8,**只省参数不提精度** | | `elmkit.model` | `ELM`、`fit_elm`、`tune_bias_ratio`(`prune=` 参数或 `model.prune()`) | | `elmkit.datasets` | 内置合成数据:`make_saddle`(八瓣鞍面回归)、`make_classification`(象限分类,可演示降维去噪) | ### 剪枝用法 ```python # 方式一:fit 时声明(推荐) m = ELM(n_components=32, n_hidden=2000, prune=dict(keep=800)).fit(X, y) m.score(X_test, y_test) # 推理路径自动走剪枝 # 方式二:先训后剪(诊断用) # ⚠️ 剪枝要重解读出、需要训练特征 H,所以必须 cache_train=True; # 只写 prune=... 时 fit 会自动缓存,不写就必须显式要求。 m = ELM(n_components=32, n_hidden=2000).fit(X, y, cache_train=True) res = m.prune(keep=800, strategy='magnitude') print(res['n_keep'], res['acc_before'], res['acc_after']) # 这两个 acc 是**训练集**上的 ``` --- --- ## 6. 测试与演示 ```bash pip install -e . # 可选:装好后可以用 -m 形式 python test_elmkit.py # 自包含冒烟测试(约 40s,不需要任何外部数据) python demo.py # 自包含演示(约 45s) # 装好后等价于: python -m elmkit.test_elmkit python -m elmkit.demo ``` 两者都**只依赖 numpy**:数据由 `elmkit.datasets` 现算(八瓣鞍面回归 + 象限分类), 不读文件、不联网。测试覆盖: | 组 | 断言内容 | |---|---| | 1 | PCA 正确性:正交归一、白化口径 `(N-1)/N`、分块一致、**不得修改调用方输入** | | 2 | `scale_fit` 的 `std(z)=1`、`bias_ratio` 生效、8 个初始化方案合法、随机流的确定性与独立性 | | 3 | 读出的数值不变量:闭式解 == 显式 `(G+λI)⁻¹HᵀT`;**自适应 λ 下 1e-3~1e3 尺度预测逐样本全同**;绝对 λ 会破坏该性质;分块一致 | | 4 | 分类端到端:PCA(2) 命中信号子空间(0.973 vs 不降维 0.785)、复现性(同种子逐位相同 / 不同种子必须不同)、任务自动判断、分块一致 | | 5 | 回归端到端:鞍面 R²>0.99;`bias_ratio=5.0` 在此任务上 RMSE 差 **34.9×**;`tune_bias_ratio` 选出 0.3 | | 6 | 剪枝:阈值 1e-4 一个都剪不掉、阈值 5e-2 剪掉 70% 只掉 0.1pp、top-k 单调、**magnitude 在 k=50 上比 random 高 0.95pp**、`prune()` 的缓存契约 | | 7 | 诊断接口:`eff_rank`、`col_cos_stats`、`decorrelate` 的 `rescale` 陷阱 | | 8 | **可选**:与历史 MNIST 实验的位级对齐(默认 SKIP) | | 9 | 收尾出图(可选依赖 matplotlib,没装就 SKIP,不算失败):9a **真值 3D 曲面**;9b **回归拟合四联图**;9c **分类拟合图** | 第 9 组的图默认写到**当前工作目录**(写 cwd 而不是包目录:装好的包可能在 site-packages 里,不该往里丢文件): | 文件 | 内容 | |---|---| | `saddle_3d.png` | 任务本身:单位圆盘上的 3D 真值曲面 + 底面等高线填充投影(4 正瓣 + 4 负瓣) | | `saddle_3d_fit.png` | **回归拟合四联图**:真值 / 拟合 / 残差 / 预测-真值散点,复用第 5 组训好的模型(图文同源) | | `classification_fit.png` | **分类拟合图**:模型输入平面(PCA 2 维)上的决策区域 + 测试点 + 错分红叉 + 混淆矩阵 | 关于 `classification_fit.png` 为什么画 PCA 平面:这个模型 `n_components=2`, 32 维输入里那 30 个噪声维**它根本看不到**,所以这个平面就是它的全部输入 —— 这张图是模型决策函数的**完整**可视化,不是"降维示意"。网格是先铺在 PC 平面、 再用 `mean_ + Z @ components_.T` 正交反投影回特征空间喂给 `predict` 的(只用公开接口)。 图上有个容易误读的地方:**决策区域的边界是斜的**。这不是模型学错,而是 PCA(2) 的两个特征值 8.920 / 8.743 几乎相等(两个信号方向方差都是 3²=9), 主轴在该子空间内**只确定到一个旋转**,所以真实象限边界在 PC 坐标里本来就是转过的。 实测准确率 0.9730 已经贴着 3% 标签噪声的误差下限。 四联图里第 2、3 张是关键:拟合面与真值面**共用同一色标**(±0.25)便于直接对拍, 残差面用**自己的对称色标**(实测 R²=0.99954、RMSE=2.19e-03、max\|err\|=1.04e-02, 若沿用真值色标会是一张全白图)。两个环境变量可调: ```bash set ELMKIT_FIG_DIR=D:\path\to\figures # 换个输出目录 set ELMKIT_SHOW_PLOT=1 # 出图后同时弹窗(有桌面环境时) ``` 出图需要 matplotlib(**可选的测试期依赖**,运行期仍然只依赖 numpy): ```bash pip install -e ".[plot]" # 或 pip install matplotlib ``` 第 8 组默认跳过(本包不需要外部数据)。要复现历史数字时自备数据并用环境变量指过去 (**代码里没有写死任何路径**): ```bash set ELMKIT_MNIST_DIR=D:\path\to\mnist_gz # 4 个 .gz 所在目录 set ELMKIT_EXP14_JSON=D:\path\to\result_dim_width.json python test_elmkit.py ``` 对齐时必须 `raw_seed=True`:当年的实验脚本用 `np.random.default_rng(seed)` **直连**, 而本包默认用哈希派生流(`_sub_rng(seed, scheme)`,方案间更独立)。 两者统计上等价,但**要对齐历史数字必须用同一条流**。实测(numpy 1.26.2 / Windows): ``` [OK ] PCA32 M=1000 acc 均值: got=0.9621 want=0.9621 [OK ] PCA32 M=2000 acc 均值: got=0.969033 want=0.969033 [OK ] PCA32 M=3000 acc 均值: got=0.973367 want=0.973367 ``` --- --- ## 7. 开发过程中修掉的 6 个 bug(留档) 写这个包的过程本身暴露了 6 个 bug,其中 2 个在**测试**里、4 个在**实现**里 (后两个是优化 PCA 复杂度时引入的,见 §9.3/§9.4): 1. **`decorrelate` 的实现顺序错了**(实现 bug,重要): v1 写成「先 `orthogonalize` → 再 `scale_fit`」,结果**正交性被自己毁掉**: ``` orthogonalize 后 行间 |cos| = 2.3e-17 再过 scale_fit 后 行间 |cos| = 8.4e-03 ← 退化 15 个数量级 ``` 原因:`scale_fit` 是**逐节点**缩放,对不同行施加不同缩放, 等价于给 Gram 做对角相似变换 ⇒ 破坏正交性。 这两个手段目标本质冲突,故默认 `rescale=False`(只正交化、不动尺度)。 反向(先 scale 再正交)能保住正交,但 `std(z)` 不再全为 1(实测 1.15~1.41)。 2. **`assert old in s` 静默失败**(工程 bug,同一类坑在别处也记过): 我改 `decorrelate` 默认值时,`old` 串没匹配上(签名行不在我替换的块里), 于是 `rescale=True` 一直没被改掉。症状是测试连续两次报同一个 `off=8.315e-03` —— **数值完全不变就是「改动没生效」的信号**,不是「结果稳定」。 3. **测试口径写反两次**(测试 bug):白化后列方差的理论值是 `(N-1)/N`(因为 PCA 协方差用 `1/(N-1)` 无偏归一,而 `np.var` 默认 `ddof=0`)。 我先写成 `1`,再写成 `N/(N-1)`,两次都错。实测 0.999500 才对。 4. **测试里变量名复用**(测试 bug):`off`(rescale=True 分支)与 `off`(默认分支)同名,导致检查的是被破坏后的矩阵,误报 FAIL。 5. **PCA 整体转 float64 + 造 `Xc` 副本**(实现 bug,内存):见 §9.3, MNIST 上峰值 759 MiB → 修成 41 MiB(省 18×)。 6. **分块后原地 `-=` 改了调用方的数组**(实现 bug,静默):见 §9.4。 `np.asarray(x, dtype=float64)` 在 x 已是 float64 时返回**视图**, 于是 `Xb -= mean` 直接改掉调用方的 X;在 `ELM.fit` 里导致 **acc 0.9716 → 0.7907**。只在 float64 输入下暴露,MNIST(float32)测不出来。 **共同教训**: - 测试报 FAIL 时,先分清是「实现错」还是「期望值错」——本包 6 个 bug 里有 2 个是期望值写错。 - 「同一数值反复出现」几乎总是改动未生效(bug 2)。 - **大幅提升的数值(acc 0.79 vs 0.97)要立刻当成 bug 查,不要当成「优化带来的副作用」**(bug 6)。 - **省内存的原地操作最危险**:`asarray` 是否复制取决于输入 dtype, 这种「依情形而定」的语义正是静默 bug 的温床;要原地改就显式 `np.array(copy=True)`。 --- ## 8. 剪枝:有,但**只省参数、不提精度** `elmkit.prune` 提供三条策略:`magnitude`(|beta|)、`energy`(‖beta_j‖·‖H_:,j‖)、 `random`(基线)。 ### 8.1 经典阈值剪枝在新配置下**一个都剪不掉** 实测(PCA32 + M=2000 + `bias_ratio=5.0`,未剪枝 0.9716): | 阈值 | 保留节点 | 剪枝后 acc | Δ | |---|---|---|---| | 1e-4 / 1e-3 / **5e-3** | 2000 | 0.9716 | +0.0000 | | 1e-2 | 1992 | 0.9713 | −0.0003 | | 5e-2 | 126 | 0.9088 | −0.0628 | 原因:`|beta|` 最小值 **6.88e-03** > 5e-3,且**死节点 = 0**。 `bias_ratio` 把每个节点都调到中庸工作区,**没有一个节点是"废"的**。 > `elm/` 历史上 `|beta| < 5e-3` 能剪掉 355/1000 —— 那是**绝对阈值 + 绝对 > C_REG=0.01** 口径的产物,换成尺度自适应的新配置就不成立了。 > 这也再次印证 §2 坑 3:绝对阈值不是尺度不变的。 ### 8.2 top-k 曲线:精度单调下降,但结构化排序确实优于随机 | 保留 k | magnitude | energy | random | |---|---|---|---| | 2000(不剪) | 0.9716 | 0.9716 | 0.9716 | | 1600 | **0.9701** | 0.9704 | 0.9676 | | 1200 | **0.9697** | 0.9666 | 0.9646 | | 800 | **0.9652** | 0.9617 | 0.9579 | | 400 | **0.9505** | 0.9421 | 0.9410 | 两条读法: 1. **剪枝换的是体积不是精度**:剪 80% 掉 2.1pp。 2. **排序确实有效,且优势随剪枝加深拉大**:k=1600 时 +0.25pp、 k=800 时 +0.73pp、k=400 时 **+0.95pp**。 ⇒ 这是剪枝"有用"的**正确证据形式** —— 必须跟 `random` 臂比; 只说"剪完还有 0.95"是证明不了排序有效的。 `magnitude` 在全部 k 上最好(k=400 时比 `energy` 高 0.84pp), 故为默认策略。 > **自包含复现**:`demo.py` 的剪枝段在象限任务上重现了同样的形状 —— > 阈值 1e-4 一个都剪不掉(1000/1000),阈值 5e-2 剪到 305/1000 只掉 0.10pp, > top-k 深剪时 `magnitude` 比 `random` 高 **0.95pp**(k=50), > 而浅剪时差异落在噪声内(k=500 时 random 甚至略高 0.15pp)。 > 即:**结论要按剪枝深度分层说**。 ### 8.3 与历史其他负结果的关系 - **exp6 ARD**:在 ELM 上零稀疏(1000/1000 全保留),根因是随机 ReLU 特征 结构性低秩(有效秩仅 1.8/1000),ARD 判定"谁都不能裁"。 - **oracle 暴力裁剪曲线**(784 维 + C_REG=0.01 口径):剪得越多越差, 且 ARD 排序只比随机好 **0.15pp** —— 比本模块新配置下的 0.95pp 弱得多。 说明**维度与正则口径会显著改变剪枝的可辨识度**。 **结论**:若要精度,别剪;若要压体积,用 `magnitude` 并接受掉点。 --- --- ## 9. PCA 的时间 / 空间复杂度(实测) ### 9.1 理论 | 阶段 | 时间复杂度 | 空间复杂度 | |---|---|---| | 中心化 + 协方差 `XcᵀXc` | **O(N·d²)** | O(d²) 存协方差 | | 特征分解 `eigh(d×d)` 全谱 | **O(d³)** | O(d²) | | `transform` 投影 | O(N·d·k) | O(N·k) 输出 | | **合计** | **O(N·d² + d³)** | **O(d²) + 输入** | 主导项取决于 **N 与 d 的相对大小**: - `N·d² ≫ d³` ⟺ **`d ≪ N`** ⇒ 由协方差主导(经典「小 d 大 N」情形) - `d ≫ N` 时应改用**样本侧**的 Gram 矩阵 `XXᵀ`(N×N),复杂度变成 O(N²d + N³) MNIST 是 `N=60000, d=784`,`N·d²/d³ = 76` ⇒ **O(N·d²) 主导**。 ### 9.2 实测(原实验机:i7-7700 4C/8G,Python 3.12,numpy 2.5.1) 固定 `N=60000`,扫 `d`: | d | 协方差矩阵 | `fit` 耗时 | log-log 斜率 | |---|---|---|---| | 784 | 4.9 MiB | 0.6 s | — | | 1568 | 19.6 MiB | 2.1 s | 1.81 | | 3136 | 78.4 MiB | 10.1 s | 2.27 | | 6272 | 314 MiB | 61.5 s | 2.61 | 固定 `d=784`,扫 `N`: | N | 输入(float64) | `fit` 耗时 | |---|---|---| | 15000 | 90 MiB | 0.16 s | | 30000 | 179 MiB | 0.36 s | | 60000 | 359 MiB | 0.46 s | | 120000 | 718 MiB | 1.17 s | **N 轴严格线性**(8× N → 7.3× 时间);**d 轴斜率从 1.81 爬到 2.61**, 正是从 `O(N·d²)` 向 `O(d³)` 过渡的证据(d=6272 时 `N·d²/d³` 降到 9.6)。 ### 9.3 一次实测出来的内存 bug(已修,留档) v1 的 `fit` 写的是 `X = np.asarray(X, dtype=np.float64)` 再 `Xc = X - mean_`, 对 **float32** 的 MNIST 输入会造两份 float64 副本。实测 `d=784, N=60000`: | 实现 | 峰值内存增量 | |---|---| | v1(整体转 float64 + `Xc` 副本) | **759 MiB** | | v2(分块累计 + 不整体转 dtype) | **41 MiB** | **省 18×。** v2 的做法是按块处理,每块只临时持有 ~32 MiB 的 float64, 协方差累计直接加到 d×d 的 float64 矩阵上(`d=784` 时仅 4.7 MiB)。 附带收益:`fit` 从 0.77s 降到 0.56s。 ### 9.4 分块版引入的原地修改 bug(已修,留档) v2 第一版为了省内存写成: ```python Xb = np.asarray(X[st:en], dtype=np.float64) Xb -= self.mean_ # ← 危险 ``` **`np.asarray(..., dtype=float64)` 在输入已是 float64 时返回视图而非副本**, 于是 `-=` 会**原地改掉调用方的 X**。后果分两层: 1. 直接后果:用户的数组被静默去均值。 2. 在 `ELM.fit` 里后果严重:`fit` 先污染 `X`,`transform` 再读**已被污染**的数据 ⇒ 端到端 **acc 从 0.9716 崩到 0.7907**。 **这个 bug 只在 float64 输入下暴露**(float32 输入时 `asarray` 会新建数组), 所以用 MNIST(float32)测不出来 —— 我是靠 `test_elmkit` 里 「PCA 不得修改调用方输入」的断言 + 端到端 0.7907 的反常才抓到的。 修法:`np.array(..., copy=True)` 显式复制,并把这条写成回归测试。 ### 9.5 实践建议 | 情形 | 建议 | |---|---| | `d ≤ ~2000`,任意 N | 直接用本实现,放心(d=2000 时协方差仅 32 MiB) | | `d` 大(≥5000) | 协方差已经是 GiB 级,`eigh` 也是 O(d³);考虑随机化 SVD(`sklearn` 的 `randomized_svd`)或先做特征哈希 | | `d ≫ N` | 改用样本侧 Gram(N×N),复杂度 O(N²d + N³) | | 内存紧 | 显式 `fit(X, blk=...)` 调小分块;输入保持 float32,别先转 float64 | **注意:numpy 没有 `randomized_svd`**,而本包**刻意不引入 sklearn** (运行期依赖只有 numpy),所以要随机化 SVD 得自己写(幂迭代 + QR)。 --- ---